mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
5f305f844f
connection. For legacy reasons, the call sender used to wait until after receiving the call answer before sending the ICE updates. The primary motivation was that if the receiving user hadn't accepted a new identity change, rather than just seeing one "Tap to Accept New Safety Number" messages for a call, they'd see one for the call offer and then a dozen more as ICE updates trickled in. We changed that behavior long ago, and effectively all clients will avoid that case, while sending ICE updates immediately will allow calls to connect without having to wait for an additional serialized round trip between the caller and call recipient. // FREEBIE
1668 lines
71 KiB
Swift
1668 lines
71 KiB
Swift
//
|
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import PromiseKit
|
|
import WebRTC
|
|
import SignalServiceKit
|
|
import SignalMessaging
|
|
|
|
/**
|
|
* `CallService` is a global singleton that manages the state of WebRTC-backed Signal Calls
|
|
* (as opposed to legacy "RedPhone Calls").
|
|
*
|
|
* It serves as a connection between the `CallUIAdapter` and the `PeerConnectionClient`.
|
|
*
|
|
* ## Signaling
|
|
*
|
|
* Signaling refers to the setup and tear down of the connection. Before the connection is established, this must happen
|
|
* out of band (using Signal Service), but once the connection is established it's possible to publish updates
|
|
* (like hangup) via the established channel.
|
|
*
|
|
* Signaling state is synchronized on the main thread and only mutated in the handleXXX family of methods.
|
|
*
|
|
* Following is a high level process of the exchange of messages that takes place during call signaling.
|
|
*
|
|
* ### Key
|
|
*
|
|
* --[SOMETHING]--> represents a message of type "Something" sent from the caller to the callee
|
|
* <--[SOMETHING]-- represents a message of type "Something" sent from the callee to the caller
|
|
* SS: Message sent via Signal Service
|
|
* DC: Message sent via WebRTC Data Channel
|
|
*
|
|
* ### Message Exchange / State Flow Overview
|
|
*
|
|
* | Caller | Callee |
|
|
* +----------------------------+-------------------------+
|
|
* Start outgoing call: `handleOutgoingCall`...
|
|
--[SS.CallOffer]-->
|
|
* ...and start generating ICE updates.
|
|
* As ICE candidates are generated, `handleLocalAddedIceCandidate` is called.
|
|
* and we *store* the ICE updates for later.
|
|
*
|
|
* Received call offer: `handleReceivedOffer`
|
|
* Send call answer
|
|
* <--[SS.CallAnswer]--
|
|
* Start generating ICE updates.
|
|
* As they are generated `handleLocalAddedIceCandidate` is called
|
|
which immediately sends the ICE updates to the Caller.
|
|
* <--[SS.ICEUpdate]-- (sent multiple times)
|
|
*
|
|
* Received CallAnswer: `handleReceivedAnswer`
|
|
* So send any stored ice updates (and send future ones immediately)
|
|
* --[SS.ICEUpdates]-->
|
|
*
|
|
* Once compatible ICE updates have been exchanged...
|
|
* both parties: `handleIceConnected`
|
|
*
|
|
* Show remote ringing UI
|
|
* Connect to offered Data Channel
|
|
* Show incoming call UI.
|
|
*
|
|
* If callee answers Call
|
|
* send connected message
|
|
* <--[DC.ConnectedMesage]--
|
|
* Received connected message
|
|
* Show Call is connected.
|
|
*
|
|
* Hang up (this could equally be sent by the Callee)
|
|
* --[DC.Hangup]-->
|
|
* --[SS.Hangup]-->
|
|
*/
|
|
|
|
enum CallError: Error {
|
|
case providerReset
|
|
case assertionError(description: String)
|
|
case disconnected
|
|
case externalError(underlyingError: Error)
|
|
case timeout(description: String)
|
|
case obsoleteCall(description: String)
|
|
}
|
|
|
|
// Should be roughly synced with Android client for consistency
|
|
private let connectingTimeoutSeconds: TimeInterval = 120
|
|
|
|
// All Observer methods will be invoked from the main thread.
|
|
protocol CallServiceObserver: class {
|
|
/**
|
|
* Fired whenever the call changes.
|
|
*/
|
|
func didUpdateCall(call: SignalCall?)
|
|
|
|
/**
|
|
* Fired whenever the local or remote video track become active or inactive.
|
|
*/
|
|
func didUpdateVideoTracks(call: SignalCall?,
|
|
localVideoTrack: RTCVideoTrack?,
|
|
remoteVideoTrack: RTCVideoTrack?)
|
|
}
|
|
|
|
// This class' state should only be accessed on the main queue.
|
|
@objc class CallService: NSObject, CallObserver, PeerConnectionClientDelegate {
|
|
|
|
// MARK: - Properties
|
|
|
|
var observers = [Weak<CallServiceObserver>]()
|
|
|
|
// MARK: Dependencies
|
|
|
|
private let accountManager: AccountManager
|
|
private let messageSender: MessageSender
|
|
private let contactsManager: OWSContactsManager
|
|
private let storageManager: TSStorageManager
|
|
|
|
// Exposed by environment.m
|
|
internal let notificationsAdapter: CallNotificationsAdapter
|
|
internal var callUIAdapter: CallUIAdapter!
|
|
|
|
// MARK: Class
|
|
|
|
static let fallbackIceServer = RTCIceServer(urlStrings: ["stun:stun1.l.google.com:19302"])
|
|
|
|
// MARK: Ivars
|
|
|
|
var peerConnectionClient: PeerConnectionClient? {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("\(self.logTag) .peerConnectionClient setter: \(oldValue != nil) -> \(peerConnectionClient != nil) \(String(describing: peerConnectionClient))")
|
|
}
|
|
}
|
|
|
|
var call: SignalCall? {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
oldValue?.removeObserver(self)
|
|
call?.addObserverAndSyncState(observer: self)
|
|
|
|
updateIsVideoEnabled()
|
|
|
|
// Prevent device from sleeping while we have an active call.
|
|
if oldValue != call {
|
|
if let oldValue = oldValue {
|
|
DeviceSleepManager.sharedInstance.removeBlock(blockObject: oldValue)
|
|
}
|
|
stopAnyCallTimer()
|
|
if let call = call {
|
|
DeviceSleepManager.sharedInstance.addBlock(blockObject: call)
|
|
self.startCallTimer()
|
|
}
|
|
}
|
|
|
|
Logger.debug("\(self.logTag) .call setter: \(oldValue?.identifiersForLogs as Optional) -> \(call?.identifiersForLogs as Optional)")
|
|
|
|
for observer in observers {
|
|
observer.value?.didUpdateCall(call: call)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Used to coordinate promises across delegate methods
|
|
private var fulfillCallConnectedPromise: (() -> Void)?
|
|
private var rejectCallConnectedPromise: ((Error) -> Void)?
|
|
|
|
// Used by waitForPeerConnectionClient to make sure any received
|
|
// ICE messages wait until the peer connection client is set up.
|
|
private var fulfillPeerConnectionClientPromise: (() -> Void)?
|
|
private var rejectPeerConnectionClientPromise: ((Error) -> Void)?
|
|
private var peerConnectionClientPromise: Promise<Void>?
|
|
|
|
// Used by waituntilReadyToSendIceUpdates to make sure CallOffer was
|
|
// sent before sending any ICE updates.
|
|
private var fulfillReadyToSendIceUpdatesPromise: (() -> Void)?
|
|
private var rejectReadyToSendIceUpdatesPromise: ((Error) -> Void)?
|
|
private var readyToSendIceUpdatesPromise: Promise<Void>?
|
|
|
|
weak var localVideoTrack: RTCVideoTrack? {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("\(self.logTag) \(#function)")
|
|
|
|
fireDidUpdateVideoTracks()
|
|
}
|
|
}
|
|
|
|
weak var remoteVideoTrack: RTCVideoTrack? {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("\(self.logTag) \(#function)")
|
|
|
|
fireDidUpdateVideoTracks()
|
|
}
|
|
}
|
|
var isRemoteVideoEnabled = false {
|
|
didSet {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("\(self.logTag) \(#function): \(isRemoteVideoEnabled)")
|
|
|
|
fireDidUpdateVideoTracks()
|
|
}
|
|
}
|
|
|
|
required init(accountManager: AccountManager, contactsManager: OWSContactsManager, messageSender: MessageSender, notificationsAdapter: CallNotificationsAdapter) {
|
|
self.accountManager = accountManager
|
|
self.contactsManager = contactsManager
|
|
self.messageSender = messageSender
|
|
self.notificationsAdapter = notificationsAdapter
|
|
self.storageManager = TSStorageManager.shared()
|
|
|
|
super.init()
|
|
|
|
SwiftSingletons.register(self)
|
|
|
|
self.createCallUIAdapter()
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(didEnterBackground),
|
|
name: NSNotification.Name.OWSApplicationDidEnterBackground,
|
|
object: nil)
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(didBecomeActive),
|
|
name: NSNotification.Name.OWSApplicationDidBecomeActive,
|
|
object: nil)
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
func didEnterBackground() {
|
|
AssertIsOnMainThread()
|
|
self.updateIsVideoEnabled()
|
|
}
|
|
|
|
func didBecomeActive() {
|
|
AssertIsOnMainThread()
|
|
self.updateIsVideoEnabled()
|
|
}
|
|
|
|
/**
|
|
* Choose whether to use CallKit or a Notification backed interface for calling.
|
|
*/
|
|
public func createCallUIAdapter() {
|
|
AssertIsOnMainThread()
|
|
|
|
if self.call != nil {
|
|
Logger.warn("\(self.logTag) ending current call in \(#function). Did user toggle callkit preference while in a call?")
|
|
self.terminateCall()
|
|
}
|
|
self.callUIAdapter = CallUIAdapter(callService: self, contactsManager: self.contactsManager, notificationsAdapter: self.notificationsAdapter)
|
|
}
|
|
|
|
// MARK: - Service Actions
|
|
|
|
/**
|
|
* Initiate an outgoing call.
|
|
*/
|
|
public func handleOutgoingCall(_ call: SignalCall) -> Promise<Void> {
|
|
AssertIsOnMainThread()
|
|
|
|
guard self.call == nil else {
|
|
let errorDescription = "\(self.logTag) call was unexpectedly already set."
|
|
Logger.error(errorDescription)
|
|
call.state = .localFailure
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallAlreadySet(), file: #file, function: #function, line: #line)
|
|
return Promise(error: CallError.assertionError(description: errorDescription))
|
|
}
|
|
|
|
self.call = call
|
|
|
|
let callRecord = TSCall(timestamp: NSDate.ows_millisecondTimeStamp(), withCallNumber: call.remotePhoneNumber, callType: RPRecentCallTypeOutgoingIncomplete, in: call.thread)
|
|
callRecord.save()
|
|
call.callRecord = callRecord
|
|
|
|
let promise = getIceServers().then { iceServers -> Promise<HardenedRTCSessionDescription> in
|
|
Logger.debug("\(self.logTag) got ice servers:\(iceServers) for call: \(call.identifiersForLogs)")
|
|
|
|
guard self.call == call else {
|
|
throw CallError.obsoleteCall(description: "obsolete call in \(#function)")
|
|
}
|
|
|
|
guard self.peerConnectionClient == nil else {
|
|
let errorDescription = "\(self.logTag) peerconnection was unexpectedly already set."
|
|
Logger.error(errorDescription)
|
|
OWSProdError(OWSAnalyticsEvents.callServicePeerConnectionAlreadySet(), file: #file, function: #function, line: #line)
|
|
throw CallError.assertionError(description: errorDescription)
|
|
}
|
|
|
|
let useTurnOnly = Environment.current().preferences.doCallsHideIPAddress()
|
|
|
|
let peerConnectionClient = PeerConnectionClient(iceServers: iceServers, delegate: self, callDirection: .outgoing, useTurnOnly: useTurnOnly)
|
|
Logger.debug("\(self.logTag) setting peerConnectionClient in \(#function) for call: \(call.identifiersForLogs)")
|
|
self.peerConnectionClient = peerConnectionClient
|
|
self.fulfillPeerConnectionClientPromise?()
|
|
|
|
return peerConnectionClient.createOffer()
|
|
}.then { (sessionDescription: HardenedRTCSessionDescription) -> Promise<Void> in
|
|
guard self.call == call else {
|
|
throw CallError.obsoleteCall(description: "obsolete call in \(#function)")
|
|
}
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
owsFail("Missing peerConnectionClient in \(#function)")
|
|
throw CallError.obsoleteCall(description: "Missing peerConnectionClient in \(#function)")
|
|
}
|
|
|
|
return peerConnectionClient.setLocalSessionDescription(sessionDescription).then {
|
|
let offerMessage = OWSCallOfferMessage(callId: call.signalingId, sessionDescription: sessionDescription.sdp)
|
|
let callMessage = OWSOutgoingCallMessage(thread: call.thread, offerMessage: offerMessage)
|
|
return self.messageSender.sendPromise(message: callMessage)
|
|
}
|
|
}.then {
|
|
guard self.call == call else {
|
|
throw CallError.obsoleteCall(description: "obsolete call in \(#function)")
|
|
}
|
|
|
|
// For outgoing calls, wait until call offer is sent before we send any ICE updates, to ensure message ordering for
|
|
// clients that don't support receiving ICE updates before receiving the call offer.
|
|
self.readyToSendIceUpdates(call: call)
|
|
|
|
let (callConnectedPromise, fulfill, reject) = Promise<Void>.pending()
|
|
self.fulfillCallConnectedPromise = fulfill
|
|
self.rejectCallConnectedPromise = reject
|
|
|
|
// Don't let the outgoing call ring forever. We don't support inbound ringing forever anyway.
|
|
let timeout: Promise<Void> = after(interval: connectingTimeoutSeconds).then { () -> Void in
|
|
// rejecting a promise by throwing is safely a no-op if the promise has already been fulfilled
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorTimeoutWhileConnectingOutgoing(), file: #file, function: #function, line: #line)
|
|
throw CallError.timeout(description: "timed out waiting to receive call answer")
|
|
}
|
|
|
|
return race(timeout, callConnectedPromise)
|
|
}.then {
|
|
Logger.info(self.call == call
|
|
? "\(self.logTag) outgoing call connected: \(call.identifiersForLogs)."
|
|
: "\(self.logTag) obsolete outgoing call connected: \(call.identifiersForLogs).")
|
|
}.catch { error in
|
|
Logger.error("\(self.logTag) placing call \(call.identifiersForLogs) failed with error: \(error)")
|
|
|
|
if let callError = error as? CallError {
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorOutgoingConnectionFailedInternal(), file: #file, function: #function, line: #line)
|
|
self.handleFailedCall(failedCall: call, error: callError)
|
|
} else {
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorOutgoingConnectionFailedExternal(), file: #file, function: #function, line: #line)
|
|
let externalError = CallError.externalError(underlyingError: error)
|
|
self.handleFailedCall(failedCall: call, error: externalError)
|
|
}
|
|
}
|
|
promise.retainUntilComplete()
|
|
return promise
|
|
}
|
|
|
|
func readyToSendIceUpdates(call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard self.call == call else {
|
|
self.handleFailedCall(failedCall: call, error: .obsoleteCall(description:"obsolete call in \(#function)"))
|
|
return
|
|
}
|
|
|
|
if self.fulfillReadyToSendIceUpdatesPromise == nil {
|
|
createReadyToSendIceUpdatesPromise()
|
|
}
|
|
|
|
guard let fulfillReadyToSendIceUpdatesPromise = self.fulfillReadyToSendIceUpdatesPromise else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceMissingFulfillReadyToSendIceUpdatesPromise(), file: #file, function: #function, line: #line)
|
|
self.handleFailedCall(failedCall: call, error: CallError.assertionError(description: "failed to create fulfillReadyToSendIceUpdatesPromise"))
|
|
return
|
|
}
|
|
|
|
fulfillReadyToSendIceUpdatesPromise()
|
|
}
|
|
|
|
/**
|
|
* Called by the call initiator after receiving a CallAnswer from the callee.
|
|
*/
|
|
public func handleReceivedAnswer(thread: TSContactThread, callId: UInt64, sessionDescription: String) {
|
|
Logger.info("\(self.logTag) received call answer for call: \(callId) thread: \(thread.contactIdentifier())")
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
Logger.warn("\(self.logTag) ignoring obsolete call: \(callId) in \(#function)")
|
|
return
|
|
}
|
|
|
|
guard call.signalingId == callId else {
|
|
Logger.warn("\(self.logTag) ignoring mismatched call: \(callId) currentCall: \(call.signalingId) in \(#function)")
|
|
return
|
|
}
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
OWSProdError(OWSAnalyticsEvents.callServicePeerConnectionMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCall(failedCall: call, error: CallError.assertionError(description: "peerConnectionClient was unexpectedly nil in \(#function)"))
|
|
return
|
|
}
|
|
|
|
let sessionDescription = RTCSessionDescription(type: .answer, sdp: sessionDescription)
|
|
let setDescriptionPromise = peerConnectionClient.setRemoteSessionDescription(sessionDescription).then {
|
|
Logger.debug("\(self.logTag) successfully set remote description")
|
|
}.catch { error in
|
|
if let callError = error as? CallError {
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorHandleReceivedErrorInternal(), file: #file, function: #function, line: #line)
|
|
self.handleFailedCall(failedCall: call, error: callError)
|
|
} else {
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorHandleReceivedErrorExternal(), file: #file, function: #function, line: #line)
|
|
let externalError = CallError.externalError(underlyingError: error)
|
|
self.handleFailedCall(failedCall: call, error: externalError)
|
|
}
|
|
}
|
|
setDescriptionPromise.retainUntilComplete()
|
|
}
|
|
|
|
/**
|
|
* User didn't answer incoming call
|
|
*/
|
|
public func handleMissedCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
|
|
// Insert missed call record
|
|
if let callRecord = call.callRecord {
|
|
if callRecord.callType == RPRecentCallTypeIncoming {
|
|
callRecord.updateCallType(RPRecentCallTypeMissed)
|
|
}
|
|
} else {
|
|
call.callRecord = TSCall(timestamp: NSDate.ows_millisecondTimeStamp(),
|
|
withCallNumber: call.thread.contactIdentifier(),
|
|
callType: RPRecentCallTypeMissed,
|
|
in: call.thread)
|
|
}
|
|
|
|
assert(call.callRecord != nil)
|
|
call.callRecord?.save()
|
|
|
|
self.callUIAdapter.reportMissedCall(call)
|
|
}
|
|
|
|
/**
|
|
* Received a call while already in another call.
|
|
*/
|
|
private func handleLocalBusyCall(_ call: SignalCall) {
|
|
Logger.info("\(self.logTag) \(#function) for call: \(call.identifiersForLogs) thread: \(call.thread.contactIdentifier())")
|
|
AssertIsOnMainThread()
|
|
|
|
let busyMessage = OWSCallBusyMessage(callId: call.signalingId)
|
|
let callMessage = OWSOutgoingCallMessage(thread: call.thread, busyMessage: busyMessage)
|
|
let sendPromise = messageSender.sendPromise(message: callMessage)
|
|
sendPromise.retainUntilComplete()
|
|
|
|
handleMissedCall(call)
|
|
}
|
|
|
|
/**
|
|
* The callee was already in another call.
|
|
*/
|
|
public func handleRemoteBusy(thread: TSContactThread, callId: UInt64) {
|
|
Logger.info("\(self.logTag) \(#function) for thread: \(thread.contactIdentifier())")
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
Logger.warn("\(self.logTag) ignoring obsolete call: \(callId) in \(#function)")
|
|
return
|
|
}
|
|
|
|
guard call.signalingId == callId else {
|
|
Logger.warn("\(self.logTag) ignoring mismatched call: \(callId) currentCall: \(call.signalingId) in \(#function)")
|
|
return
|
|
}
|
|
|
|
guard thread.contactIdentifier() == call.remotePhoneNumber else {
|
|
Logger.warn("\(self.logTag) ignoring obsolete call in \(#function)")
|
|
return
|
|
}
|
|
|
|
call.state = .remoteBusy
|
|
callUIAdapter.remoteBusy(call)
|
|
terminateCall()
|
|
}
|
|
|
|
/**
|
|
* Received an incoming call offer. We still have to complete setting up the Signaling channel before we notify
|
|
* the user of an incoming call.
|
|
*/
|
|
public func handleReceivedOffer(thread: TSContactThread, callId: UInt64, sessionDescription callerSessionDescription: String) {
|
|
AssertIsOnMainThread()
|
|
|
|
let newCall = SignalCall.incomingCall(localId: UUID(), remotePhoneNumber: thread.contactIdentifier(), signalingId: callId)
|
|
|
|
Logger.info("\(self.logTag) receivedCallOffer: \(newCall.identifiersForLogs)")
|
|
|
|
let untrustedIdentity = OWSIdentityManager.shared().untrustedIdentityForSending(toRecipientId: thread.contactIdentifier())
|
|
|
|
guard untrustedIdentity == nil else {
|
|
Logger.warn("\(self.logTag) missed a call due to untrusted identity: \(newCall.identifiersForLogs)")
|
|
|
|
let callerName = self.contactsManager.displayName(forPhoneIdentifier: thread.contactIdentifier())
|
|
|
|
switch untrustedIdentity!.verificationState {
|
|
case .verified:
|
|
owsFail("\(self.logTag) shouldn't have missed a call due to untrusted identity if the identity is verified")
|
|
self.notificationsAdapter.presentMissedCall(newCall, callerName: callerName)
|
|
case .default:
|
|
self.notificationsAdapter.presentMissedCallBecauseOfNewIdentity(call: newCall, callerName: callerName)
|
|
case .noLongerVerified:
|
|
self.notificationsAdapter.presentMissedCallBecauseOfNoLongerVerifiedIdentity(call: newCall, callerName: callerName)
|
|
}
|
|
|
|
let callRecord = TSCall(timestamp: NSDate.ows_millisecondTimeStamp(),
|
|
withCallNumber: thread.contactIdentifier(),
|
|
callType: RPRecentCallTypeMissedBecauseOfChangedIdentity,
|
|
in: thread)
|
|
assert(newCall.callRecord == nil)
|
|
newCall.callRecord = callRecord
|
|
callRecord.save()
|
|
|
|
terminateCall()
|
|
|
|
return
|
|
}
|
|
|
|
guard self.call == nil else {
|
|
let existingCall = self.call!
|
|
|
|
// TODO on iOS10+ we can use CallKit to swap calls rather than just returning busy immediately.
|
|
Logger.info("\(self.logTag) receivedCallOffer: \(newCall.identifiersForLogs) but we're already in call: \(existingCall.identifiersForLogs)")
|
|
|
|
handleLocalBusyCall(newCall)
|
|
|
|
if existingCall.remotePhoneNumber == newCall.remotePhoneNumber {
|
|
Logger.info("\(self.logTag) handling call from current call user as remote busy.: \(newCall.identifiersForLogs) but we're already in call: \(existingCall.identifiersForLogs)")
|
|
|
|
// If we're receiving a new call offer from the user we already think we have a call with,
|
|
// terminate our current call to get back to a known good state. If they call back, we'll
|
|
// be ready.
|
|
//
|
|
// TODO: Auto-accept this incoming call if our current call was either a) outgoing or
|
|
// b) never connected. There will be a bit of complexity around making sure that two
|
|
// parties that call each other at the same time end up connected.
|
|
switch existingCall.state {
|
|
case .idle, .dialing, .remoteRinging:
|
|
// If both users are trying to call each other at the same time,
|
|
// both should see busy.
|
|
handleRemoteBusy(thread: existingCall.thread, callId: existingCall.signalingId)
|
|
case .answering, .localRinging, .connected, .localFailure, .localHangup, .remoteHangup, .remoteBusy:
|
|
// If one user calls another while the other has a "vestigial" call with
|
|
// that same user, fail the old call.
|
|
terminateCall()
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
Logger.info("\(self.logTag) starting new call: \(newCall.identifiersForLogs)")
|
|
|
|
self.call = newCall
|
|
|
|
var backgroundTask = OWSBackgroundTask(label:"\(#function)", completionBlock: { [weak self] status in
|
|
AssertIsOnMainThread()
|
|
|
|
guard status == .expired else {
|
|
return
|
|
}
|
|
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
let timeout = CallError.timeout(description: "background task time ran out before call connected.")
|
|
|
|
guard strongSelf.call == newCall else {
|
|
Logger.warn("\(strongSelf.logTag) ignoring obsolete call in \(#function)")
|
|
return
|
|
}
|
|
strongSelf.handleFailedCall(failedCall: newCall, error: timeout)
|
|
})
|
|
|
|
let incomingCallPromise = firstly {
|
|
return getIceServers()
|
|
}.then { (iceServers: [RTCIceServer]) -> Promise<HardenedRTCSessionDescription> in
|
|
// FIXME for first time call recipients I think we'll see mic/camera permission requests here,
|
|
// even though, from the users perspective, no incoming call is yet visible.
|
|
guard self.call == newCall else {
|
|
throw CallError.obsoleteCall(description: "getIceServers() response for obsolete call")
|
|
}
|
|
assert(self.peerConnectionClient == nil, "Unexpected PeerConnectionClient instance")
|
|
|
|
// For contacts not stored in our system contacts, we assume they are an unknown caller, and we force
|
|
// a TURN connection, so as not to reveal any connectivity information (IP/port) to the caller.
|
|
let unknownCaller = self.contactsManager.signalAccount(forRecipientId: thread.contactIdentifier()) == nil
|
|
|
|
let useTurnOnly = unknownCaller || Environment.current().preferences.doCallsHideIPAddress()
|
|
|
|
Logger.debug("\(self.logTag) setting peerConnectionClient in \(#function) for: \(newCall.identifiersForLogs)")
|
|
let peerConnectionClient = PeerConnectionClient(iceServers: iceServers, delegate: self, callDirection: .incoming, useTurnOnly: useTurnOnly)
|
|
self.peerConnectionClient = peerConnectionClient
|
|
self.fulfillPeerConnectionClientPromise?()
|
|
|
|
let offerSessionDescription = RTCSessionDescription(type: .offer, sdp: callerSessionDescription)
|
|
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
|
|
|
// Find a sessionDescription compatible with my constraints and the remote sessionDescription
|
|
return peerConnectionClient.negotiateSessionDescription(remoteDescription: offerSessionDescription, constraints: constraints)
|
|
}.then { (negotiatedSessionDescription: HardenedRTCSessionDescription) in
|
|
Logger.debug("\(self.logTag) set the remote description for: \(newCall.identifiersForLogs)")
|
|
|
|
guard self.call == newCall else {
|
|
throw CallError.obsoleteCall(description: "negotiateSessionDescription() response for obsolete call")
|
|
}
|
|
|
|
let answerMessage = OWSCallAnswerMessage(callId: newCall.signalingId, sessionDescription: negotiatedSessionDescription.sdp)
|
|
let callAnswerMessage = OWSOutgoingCallMessage(thread: thread, answerMessage: answerMessage)
|
|
|
|
return self.messageSender.sendPromise(message: callAnswerMessage)
|
|
}.then {
|
|
guard self.call == newCall else {
|
|
throw CallError.obsoleteCall(description: "sendPromise(message: ) response for obsolete call")
|
|
}
|
|
Logger.debug("\(self.logTag) successfully sent callAnswerMessage for: \(newCall.identifiersForLogs)")
|
|
|
|
// There's nothing technically forbidding receiving ICE updates before receiving the CallAnswer, but this
|
|
// a more intuitive ordering.
|
|
self.readyToSendIceUpdates(call: newCall)
|
|
|
|
let (promise, fulfill, reject) = Promise<Void>.pending()
|
|
|
|
let timeout: Promise<Void> = after(interval: connectingTimeoutSeconds).then { () -> Void in
|
|
// rejecting a promise by throwing is safely a no-op if the promise has already been fulfilled
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorTimeoutWhileConnectingIncoming(), file: #file, function: #function, line: #line)
|
|
throw CallError.timeout(description: "timed out waiting for call to connect")
|
|
}
|
|
|
|
// This will be fulfilled (potentially) by the RTCDataChannel delegate method
|
|
self.fulfillCallConnectedPromise = fulfill
|
|
self.rejectCallConnectedPromise = reject
|
|
|
|
return race(promise, timeout)
|
|
}.then {
|
|
Logger.info(self.call == newCall
|
|
? "\(self.logTag) incoming call connected: \(newCall.identifiersForLogs)."
|
|
: "\(self.logTag) obsolete incoming call connected: \(newCall.identifiersForLogs).")
|
|
}.catch { error in
|
|
guard self.call == newCall else {
|
|
Logger.debug("\(self.logTag) ignoring error: \(error) for obsolete call: \(newCall.identifiersForLogs).")
|
|
return
|
|
}
|
|
if let callError = error as? CallError {
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorIncomingConnectionFailedInternal(), file: #file, function: #function, line: #line)
|
|
self.handleFailedCall(failedCall: newCall, error: callError)
|
|
} else {
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorIncomingConnectionFailedExternal(), file: #file, function: #function, line: #line)
|
|
let externalError = CallError.externalError(underlyingError: error)
|
|
self.handleFailedCall(failedCall: newCall, error: externalError)
|
|
}
|
|
}.always {
|
|
Logger.debug("\(self.logTag) ending background task awaiting inbound call connection")
|
|
|
|
backgroundTask = nil
|
|
}
|
|
incomingCallPromise.retainUntilComplete()
|
|
}
|
|
|
|
/**
|
|
* Remote client (could be caller or callee) sent us a connectivity update
|
|
*/
|
|
public func handleRemoteAddedIceCandidate(thread: TSContactThread, callId: UInt64, sdp: String, lineIndex: Int32, mid: String) {
|
|
waitForPeerConnectionClient().then { () -> Void in
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
Logger.warn("ignoring remote ice update for thread: \(String(describing: thread.uniqueId)) since there is no current call. Call already ended?")
|
|
return
|
|
}
|
|
|
|
guard call.signalingId == callId else {
|
|
Logger.warn("\(self.logTag) ignoring mismatched call: \(callId) currentCall: \(call.signalingId) in \(#function)")
|
|
return
|
|
}
|
|
|
|
guard thread.contactIdentifier() == call.thread.contactIdentifier() else {
|
|
Logger.warn("ignoring remote ice update for thread: \(String(describing: thread.uniqueId)) due to thread mismatch. Call already ended?")
|
|
return
|
|
}
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
Logger.warn("ignoring remote ice update for thread: \(String(describing: thread.uniqueId)) since there is no current peerConnectionClient. Call already ended?")
|
|
return
|
|
}
|
|
|
|
peerConnectionClient.addRemoteIceCandidate(RTCIceCandidate(sdp: sdp, sdpMLineIndex: lineIndex, sdpMid: mid))
|
|
}.catch { error in
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorHandleRemoteAddedIceCandidate(), file: #file, function: #function, line: #line)
|
|
Logger.error("\(self.logTag) in \(#function) waitForPeerConnectionClient failed with error: \(error)")
|
|
}.retainUntilComplete()
|
|
}
|
|
|
|
/**
|
|
* Local client (could be caller or callee) generated some connectivity information that we should send to the
|
|
* remote client.
|
|
*/
|
|
private func handleLocalAddedIceCandidate(_ iceCandidate: RTCIceCandidate) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
self.handleFailedCurrentCall(error: CallError.assertionError(description: "ignoring local ice candidate, since there is no current call."))
|
|
return
|
|
}
|
|
|
|
// Wait until we've sent the CallOffer before sending any ice updates for the call to ensure
|
|
// intuitive message ordering for other clients.
|
|
waitUntilReadyToSendIceUpdates().then { () -> Void in
|
|
guard call == self.call else {
|
|
self.handleFailedCurrentCall(error: .obsoleteCall(description: "current call changed since we became ready to send ice updates"))
|
|
return
|
|
}
|
|
|
|
guard call.state != .idle else {
|
|
// This will only be called for the current peerConnectionClient, so
|
|
// fail the current call.
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallUnexpectedlyIdle(), file: #file, function: #function, line: #line)
|
|
self.handleFailedCurrentCall(error: CallError.assertionError(description: "ignoring local ice candidate, since call is now idle."))
|
|
return
|
|
}
|
|
|
|
let iceUpdateMessage = OWSCallIceUpdateMessage(callId: call.signalingId, sdp: iceCandidate.sdp, sdpMLineIndex: iceCandidate.sdpMLineIndex, sdpMid: iceCandidate.sdpMid)
|
|
|
|
Logger.info("\(self.logTag) in \(#function) sending ICE Candidate.")
|
|
let callMessage = OWSOutgoingCallMessage(thread: call.thread, iceUpdateMessage: iceUpdateMessage)
|
|
let sendPromise = self.messageSender.sendPromise(message: callMessage)
|
|
sendPromise.retainUntilComplete()
|
|
}.catch { error in
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorHandleLocalAddedIceCandidate(), file: #file, function: #function, line: #line)
|
|
Logger.error("\(self.logTag) in \(#function) waitUntilReadyToSendIceUpdates failed with error: \(error)")
|
|
}.retainUntilComplete()
|
|
}
|
|
|
|
/**
|
|
* The clients can now communicate via WebRTC.
|
|
*
|
|
* Called by both caller and callee. Compatible ICE messages have been exchanged between the local and remote
|
|
* client.
|
|
*/
|
|
private func handleIceConnected() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
// This will only be called for the current peerConnectionClient, so
|
|
// fail the current call.
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) ignoring \(#function) since there is no current call."))
|
|
return
|
|
}
|
|
|
|
Logger.info("\(self.logTag) in \(#function): \(call.identifiersForLogs).")
|
|
|
|
switch call.state {
|
|
case .dialing:
|
|
call.state = .remoteRinging
|
|
case .answering:
|
|
call.state = .localRinging
|
|
self.callUIAdapter.reportIncomingCall(call, thread: call.thread)
|
|
case .remoteRinging:
|
|
Logger.info("\(self.logTag) call already ringing. Ignoring \(#function): \(call.identifiersForLogs).")
|
|
case .connected:
|
|
Logger.info("\(self.logTag) Call reconnected \(#function): \(call.identifiersForLogs).")
|
|
default:
|
|
Logger.debug("\(self.logTag) unexpected call state for \(#function): \(call.state): \(call.identifiersForLogs).")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The remote client (caller or callee) ended the call.
|
|
*/
|
|
public func handleRemoteHangup(thread: TSContactThread, callId: UInt64) {
|
|
Logger.debug("\(self.logTag) in \(#function)")
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
// This may happen if we hang up slightly before they hang up.
|
|
handleFailedCurrentCall(error: .obsoleteCall(description:"\(self.logTag) call was unexpectedly nil in \(#function)"))
|
|
return
|
|
}
|
|
|
|
guard call.signalingId == callId else {
|
|
Logger.warn("\(self.logTag) ignoring mismatched call: \(callId) currentCall: \(call.signalingId) in \(#function)")
|
|
return
|
|
}
|
|
|
|
guard thread.contactIdentifier() == call.thread.contactIdentifier() else {
|
|
// This can safely be ignored.
|
|
// We don't want to fail the current call because an old call was slow to send us the hangup message.
|
|
Logger.warn("\(self.logTag) ignoring hangup for thread: \(thread.contactIdentifier()) which is not the current call: \(call.identifiersForLogs)")
|
|
return
|
|
}
|
|
|
|
Logger.info("\(self.logTag) in \(#function): \(call.identifiersForLogs).")
|
|
|
|
switch call.state {
|
|
case .idle, .dialing, .answering, .localRinging, .localFailure, .remoteBusy, .remoteRinging:
|
|
handleMissedCall(call)
|
|
case .connected, .localHangup, .remoteHangup:
|
|
Logger.info("\(self.logTag) call is finished.")
|
|
}
|
|
|
|
call.state = .remoteHangup
|
|
// Notify UI
|
|
callUIAdapter.remoteDidHangupCall(call)
|
|
|
|
// self.call is nil'd in `terminateCall`, so it's important we update it's state *before* calling `terminateCall`
|
|
terminateCall()
|
|
}
|
|
|
|
/**
|
|
* User chose to answer call referred to by call `localId`. Used by the Callee only.
|
|
*
|
|
* Used by notification actions which can't serialize a call object.
|
|
*/
|
|
public func handleAnswerCall(localId: UUID) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) call was unexpectedly nil in \(#function)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) call was unexpectedly nil in \(#function)"))
|
|
return
|
|
}
|
|
|
|
guard call.localId == localId else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) callLocalId:\(localId) doesn't match current calls: \(call.localId)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallIdMismatch(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) callLocalId:\(localId) doesn't match current calls: \(call.localId)"))
|
|
return
|
|
}
|
|
|
|
self.handleAnswerCall(call)
|
|
}
|
|
|
|
/**
|
|
* User chose to answer call referred to by call `localId`. Used by the Callee only.
|
|
*/
|
|
public func handleAnswerCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("\(self.logTag) in \(#function)")
|
|
|
|
guard let currentCall = self.call else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCall(failedCall: call, error: CallError.assertionError(description: "\(self.logTag) ignoring \(#function) since there is no current call"))
|
|
return
|
|
}
|
|
|
|
guard call == currentCall else {
|
|
// This could conceivably happen if the other party of an old call was slow to send us their answer
|
|
// and we've subsequently engaged in another call. Don't kill the current call, but just ignore it.
|
|
Logger.warn("\(self.logTag) ignoring \(#function) for call other than current call")
|
|
return
|
|
}
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
OWSProdError(OWSAnalyticsEvents.callServicePeerConnectionMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCall(failedCall: call, error: CallError.assertionError(description: "\(self.logTag) missing peerconnection client in \(#function)"))
|
|
return
|
|
}
|
|
|
|
Logger.info("\(self.logTag) in \(#function): \(call.identifiersForLogs).")
|
|
|
|
let callRecord = TSCall(timestamp: NSDate.ows_millisecondTimeStamp(), withCallNumber: call.remotePhoneNumber, callType: RPRecentCallTypeIncomingIncomplete, in: call.thread)
|
|
callRecord.save()
|
|
call.callRecord = callRecord
|
|
|
|
let message = DataChannelMessage.forConnected(callId: call.signalingId)
|
|
peerConnectionClient.sendDataChannelMessage(data: message.asData(), description: "connected", isCritical: true)
|
|
|
|
handleConnectedCall(call)
|
|
}
|
|
|
|
/**
|
|
* For outgoing call, when the callee has chosen to accept the call.
|
|
* For incoming call, when the local user has chosen to accept the call.
|
|
*/
|
|
func handleConnectedCall(_ call: SignalCall) {
|
|
Logger.info("\(self.logTag) in \(#function)")
|
|
AssertIsOnMainThread()
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
OWSProdError(OWSAnalyticsEvents.callServicePeerConnectionMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCall(failedCall: call, error: CallError.assertionError(description: "\(self.logTag) peerConnectionClient unexpectedly nil in \(#function)"))
|
|
return
|
|
}
|
|
|
|
Logger.info("\(self.logTag) handleConnectedCall: \(call.identifiersForLogs).")
|
|
|
|
assert(self.fulfillCallConnectedPromise != nil)
|
|
// cancel connection timeout
|
|
self.fulfillCallConnectedPromise?()
|
|
|
|
call.state = .connected
|
|
|
|
// We don't risk transmitting any media until the remote client has admitted to being connected.
|
|
ensureAudioState(call: call, peerConnectionClient: peerConnectionClient)
|
|
peerConnectionClient.setLocalVideoEnabled(enabled: shouldHaveLocalVideoTrack())
|
|
}
|
|
|
|
/**
|
|
* Local user chose to decline the call vs. answering it.
|
|
*
|
|
* The call is referred to by call `localId`, which is included in Notification actions.
|
|
*
|
|
* Incoming call only.
|
|
*/
|
|
public func handleDeclineCall(localId: UUID) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) call was unexpectedly nil in \(#function)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) call was unexpectedly nil in \(#function)"))
|
|
return
|
|
}
|
|
|
|
guard call.localId == localId else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) callLocalId:\(localId) doesn't match current calls: \(call.localId)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallIdMismatch(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) callLocalId:\(localId) doesn't match current calls: \(call.localId)"))
|
|
return
|
|
}
|
|
|
|
self.handleDeclineCall(call)
|
|
}
|
|
|
|
/**
|
|
* Local user chose to decline the call vs. answering it.
|
|
*
|
|
* Incoming call only.
|
|
*/
|
|
public func handleDeclineCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("\(self.logTag) in \(#function): \(call.identifiersForLogs).")
|
|
|
|
if let callRecord = call.callRecord {
|
|
owsFail("Not expecting callrecord to already be set")
|
|
callRecord.updateCallType(RPRecentCallTypeIncomingDeclined)
|
|
} else {
|
|
let callRecord = TSCall(timestamp: NSDate.ows_millisecondTimeStamp(), withCallNumber: call.remotePhoneNumber, callType: RPRecentCallTypeIncomingDeclined, in: call.thread)
|
|
callRecord.save()
|
|
call.callRecord = callRecord
|
|
}
|
|
|
|
// Currently we just handle this as a hangup. But we could offer more descriptive action. e.g. DataChannel message
|
|
handleLocalHungupCall(call)
|
|
}
|
|
|
|
/**
|
|
* Local user chose to end the call.
|
|
*
|
|
* Can be used for Incoming and Outgoing calls.
|
|
*/
|
|
func handleLocalHungupCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let currentCall = self.call else {
|
|
Logger.info("\(self.logTag) in \(#function), but no current call. Other party hung up just before us.")
|
|
|
|
// terminating the call might be redundant, but it shouldn't hurt.
|
|
terminateCall()
|
|
return
|
|
}
|
|
|
|
guard call == currentCall else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMismatch(), file: #file, function: #function, line: #line)
|
|
handleFailedCall(failedCall: call, error: CallError.assertionError(description: "\(self.logTag) ignoring \(#function) for call other than current call"))
|
|
return
|
|
}
|
|
|
|
Logger.info("\(self.logTag) in \(#function): \(call.identifiersForLogs).")
|
|
|
|
call.state = .localHangup
|
|
|
|
// TODO something like this lifted from Signal-Android.
|
|
// this.accountManager.cancelInFlightRequests();
|
|
// this.messageSender.cancelInFlightRequests();
|
|
|
|
if let peerConnectionClient = self.peerConnectionClient {
|
|
// Stop audio capture ASAP
|
|
ensureAudioState(call: call, peerConnectionClient: peerConnectionClient)
|
|
|
|
// If the call is connected, we can send the hangup via the data channel for faster hangup.
|
|
let message = DataChannelMessage.forHangup(callId: call.signalingId)
|
|
peerConnectionClient.sendDataChannelMessage(data: message.asData(), description: "hangup", isCritical: true)
|
|
} else {
|
|
Logger.info("\(self.logTag) ending call before peer connection created. Device offline or quick hangup.")
|
|
}
|
|
|
|
// If the call hasn't started yet, we don't have a data channel to communicate the hang up. Use Signal Service Message.
|
|
let hangupMessage = OWSCallHangupMessage(callId: call.signalingId)
|
|
let callMessage = OWSOutgoingCallMessage(thread: call.thread, hangupMessage: hangupMessage)
|
|
let sendPromise = self.messageSender.sendPromise(message: callMessage).then {
|
|
Logger.debug("\(self.logTag) successfully sent hangup call message to \(call.thread.contactIdentifier())")
|
|
}.catch { error in
|
|
OWSProdInfo(OWSAnalyticsEvents.callServiceErrorHandleLocalHungupCall(), file: #file, function: #function, line: #line)
|
|
Logger.error("\(self.logTag) failed to send hangup call message to \(call.thread.contactIdentifier()) with error: \(error)")
|
|
}
|
|
sendPromise.retainUntilComplete()
|
|
|
|
terminateCall()
|
|
}
|
|
|
|
/**
|
|
* Local user toggled to mute audio.
|
|
*
|
|
* Can be used for Incoming and Outgoing calls.
|
|
*/
|
|
func setIsMuted(call: SignalCall, isMuted: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard call == self.call else {
|
|
// This can happen after a call has ended. Reproducible on iOS11, when the other party ends the call.
|
|
Logger.info("\(self.logTag) ignoring mute request for obsolete call")
|
|
return
|
|
}
|
|
|
|
call.isMuted = isMuted
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
// The peer connection might not be created yet.
|
|
return
|
|
}
|
|
|
|
ensureAudioState(call: call, peerConnectionClient: peerConnectionClient)
|
|
}
|
|
|
|
/**
|
|
* Local user toggled to hold call. Currently only possible via CallKit screen,
|
|
* e.g. when another Call comes in.
|
|
*/
|
|
func setIsOnHold(call: SignalCall, isOnHold: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard call == self.call else {
|
|
Logger.info("\(self.logTag) ignoring held request for obsolete call")
|
|
return
|
|
}
|
|
|
|
call.isOnHold = isOnHold
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
// The peer connection might not be created yet.
|
|
return
|
|
}
|
|
|
|
ensureAudioState(call: call, peerConnectionClient: peerConnectionClient)
|
|
}
|
|
|
|
func ensureAudioState(call: SignalCall, peerConnectionClient: PeerConnectionClient) {
|
|
guard call.state == .connected else {
|
|
peerConnectionClient.setAudioEnabled(enabled: false)
|
|
return
|
|
}
|
|
guard !call.isMuted else {
|
|
peerConnectionClient.setAudioEnabled(enabled: false)
|
|
return
|
|
}
|
|
guard !call.isOnHold else {
|
|
peerConnectionClient.setAudioEnabled(enabled: false)
|
|
return
|
|
}
|
|
|
|
peerConnectionClient.setAudioEnabled(enabled: true)
|
|
}
|
|
|
|
/**
|
|
* Local user toggled video.
|
|
*
|
|
* Can be used for Incoming and Outgoing calls.
|
|
*/
|
|
func setHasLocalVideo(hasLocalVideo: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let frontmostViewController = UIApplication.shared.frontmostViewController else {
|
|
owsFail("\(self.logTag) could not identify frontmostViewController in \(#function)")
|
|
return
|
|
}
|
|
|
|
frontmostViewController.ows_ask(forCameraPermissions: { [weak self] granted in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
if (granted) {
|
|
// Success callback; camera permissions are granted.
|
|
strongSelf.setHasLocalVideoWithCameraPermissions(hasLocalVideo: hasLocalVideo)
|
|
} else {
|
|
// Failed callback; camera permissions are _NOT_ granted.
|
|
|
|
// We don't need to worry about the user granting or remoting this permission
|
|
// during a call while the app is in the background, because changing this
|
|
// permission kills the app.
|
|
OWSAlerts.showAlert(withTitle: NSLocalizedString("MISSING_CAMERA_PERMISSION_TITLE", comment: "Alert title when camera is not authorized"),
|
|
message: NSLocalizedString("MISSING_CAMERA_PERMISSION_MESSAGE", comment: "Alert body when camera is not authorized"))
|
|
}
|
|
})
|
|
}
|
|
|
|
private func setHasLocalVideoWithCameraPermissions(hasLocalVideo: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) call was unexpectedly nil in \(#function)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) call unexpectedly nil in \(#function)"))
|
|
return
|
|
}
|
|
|
|
call.hasLocalVideo = hasLocalVideo
|
|
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
// The peer connection might not be created yet.
|
|
return
|
|
}
|
|
|
|
if call.state == .connected {
|
|
peerConnectionClient.setLocalVideoEnabled(enabled: shouldHaveLocalVideoTrack())
|
|
}
|
|
}
|
|
|
|
func handleCallKitStartVideo() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.setHasLocalVideo(hasLocalVideo: true)
|
|
}
|
|
|
|
/**
|
|
* Local client received a message on the WebRTC data channel.
|
|
*
|
|
* The WebRTC data channel is a faster signaling channel than out of band Signal Service messages. Once it's
|
|
* established we use it to communicate further signaling information. The one sort-of exception is that with
|
|
* hangup messages we redundantly send a Signal Service hangup message, which is more reliable, and since the hangup
|
|
* action is idemptotent, there's no harm done.
|
|
*
|
|
* Used by both Incoming and Outgoing calls.
|
|
*/
|
|
private func handleDataChannelMessage(_ message: OWSWebRTCProtosData) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) received data message, but there is no current call. Ignoring.")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallMissing(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) received data message, but there is no current call. Ignoring."))
|
|
return
|
|
}
|
|
|
|
if message.hasConnected() {
|
|
Logger.debug("\(self.logTag) remote participant sent Connected via data channel: \(call.identifiersForLogs).")
|
|
|
|
let connected = message.connected!
|
|
|
|
guard connected.id == call.signalingId else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) received connected message for call with id:\(connected.id) but current call has id:\(call.signalingId)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallIdMismatch(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) received connected message for call with id:\(connected.id) but current call has id:\(call.signalingId)"))
|
|
return
|
|
}
|
|
|
|
self.callUIAdapter.recipientAcceptedCall(call)
|
|
handleConnectedCall(call)
|
|
|
|
} else if message.hasHangup() {
|
|
Logger.debug("\(self.logTag) remote participant sent Hangup via data channel: \(call.identifiersForLogs).")
|
|
|
|
let hangup = message.hangup!
|
|
|
|
guard hangup.id == call.signalingId else {
|
|
// This should never happen; return to a known good state.
|
|
owsFail("\(self.logTag) received hangup message for call with id:\(hangup.id) but current call has id:\(call.signalingId)")
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallIdMismatch(), file: #file, function: #function, line: #line)
|
|
handleFailedCurrentCall(error: CallError.assertionError(description: "\(self.logTag) received hangup message for call with id:\(hangup.id) but current call has id:\(call.signalingId)"))
|
|
return
|
|
}
|
|
|
|
handleRemoteHangup(thread: call.thread, callId: hangup.id)
|
|
} else if message.hasVideoStreamingStatus() {
|
|
Logger.debug("\(self.logTag) remote participant sent VideoStreamingStatus via data channel: \(call.identifiersForLogs).")
|
|
|
|
self.isRemoteVideoEnabled = message.videoStreamingStatus.enabled()
|
|
} else {
|
|
Logger.info("\(self.logTag) received unknown or empty DataChannelMessage: \(call.identifiersForLogs).")
|
|
}
|
|
}
|
|
|
|
// MARK: - PeerConnectionClientDelegate
|
|
|
|
/**
|
|
* The connection has been established. The clients can now communicate.
|
|
*/
|
|
internal func peerConnectionClientIceConnected(_ peerConnectionClient: PeerConnectionClient) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard peerConnectionClient == self.peerConnectionClient else {
|
|
Logger.debug("\(self.logTag) \(#function) Ignoring event from obsolete peerConnectionClient")
|
|
return
|
|
}
|
|
|
|
self.handleIceConnected()
|
|
}
|
|
|
|
/**
|
|
* The connection failed to establish. The clients will not be able to communicate.
|
|
*/
|
|
internal func peerConnectionClientIceFailed(_ peerConnectionClient: PeerConnectionClient) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard peerConnectionClient == self.peerConnectionClient else {
|
|
Logger.debug("\(self.logTag) \(#function) Ignoring event from obsolete peerConnectionClient")
|
|
return
|
|
}
|
|
|
|
// Return to a known good state.
|
|
self.handleFailedCurrentCall(error: CallError.disconnected)
|
|
}
|
|
|
|
/**
|
|
* During the Signaling process each client generates IceCandidates locally, which contain information about how to
|
|
* reach the local client via the internet. The delegate must shuttle these IceCandates to the other (remote) client
|
|
* out of band, as part of establishing a connection over WebRTC.
|
|
*/
|
|
internal func peerConnectionClient(_ peerConnectionClient: PeerConnectionClient, addedLocalIceCandidate iceCandidate: RTCIceCandidate) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard peerConnectionClient == self.peerConnectionClient else {
|
|
Logger.debug("\(self.logTag) \(#function) Ignoring event from obsolete peerConnectionClient")
|
|
return
|
|
}
|
|
|
|
self.handleLocalAddedIceCandidate(iceCandidate)
|
|
}
|
|
|
|
/**
|
|
* Once the peerconnection is established, we can receive messages via the data channel, and notify the delegate.
|
|
*/
|
|
internal func peerConnectionClient(_ peerConnectionClient: PeerConnectionClient, received dataChannelMessage: OWSWebRTCProtosData) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard peerConnectionClient == self.peerConnectionClient else {
|
|
Logger.debug("\(self.logTag) \(#function) Ignoring event from obsolete peerConnectionClient")
|
|
return
|
|
}
|
|
|
|
self.handleDataChannelMessage(dataChannelMessage)
|
|
}
|
|
|
|
internal func peerConnectionClient(_ peerConnectionClient: PeerConnectionClient, didUpdateLocal videoTrack: RTCVideoTrack?) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard peerConnectionClient == self.peerConnectionClient else {
|
|
Logger.debug("\(self.logTag) \(#function) Ignoring event from obsolete peerConnectionClient")
|
|
return
|
|
}
|
|
|
|
self.localVideoTrack = videoTrack
|
|
}
|
|
|
|
internal func peerConnectionClient(_ peerConnectionClient: PeerConnectionClient, didUpdateRemote videoTrack: RTCVideoTrack?) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard peerConnectionClient == self.peerConnectionClient else {
|
|
Logger.debug("\(self.logTag) \(#function) Ignoring event from obsolete peerConnectionClient")
|
|
return
|
|
}
|
|
|
|
self.remoteVideoTrack = videoTrack
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private func waitUntilReadyToSendIceUpdates() -> Promise<Void> {
|
|
AssertIsOnMainThread()
|
|
|
|
if self.readyToSendIceUpdatesPromise == nil {
|
|
createReadyToSendIceUpdatesPromise()
|
|
}
|
|
|
|
guard let readyToSendIceUpdatesPromise = self.readyToSendIceUpdatesPromise else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCouldNotCreateReadyToSendIceUpdatesPromise(), file: #file, function: #function, line: #line)
|
|
return Promise(error: CallError.assertionError(description: "failed to create readyToSendIceUpdatesPromise"))
|
|
}
|
|
|
|
return readyToSendIceUpdatesPromise
|
|
}
|
|
|
|
private func createReadyToSendIceUpdatesPromise() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard self.readyToSendIceUpdatesPromise == nil else {
|
|
owsFail("expected readyToSendIceUpdatesPromise to be nil")
|
|
return
|
|
}
|
|
|
|
guard self.fulfillReadyToSendIceUpdatesPromise == nil else {
|
|
owsFail("expected fulfillReadyToSendIceUpdatesPromise to be nil")
|
|
return
|
|
}
|
|
|
|
guard self.rejectReadyToSendIceUpdatesPromise == nil else {
|
|
owsFail("expected rejectReadyToSendIceUpdatesPromise to be nil")
|
|
return
|
|
}
|
|
|
|
let (promise, fulfill, reject) = Promise<Void>.pending()
|
|
self.fulfillReadyToSendIceUpdatesPromise = fulfill
|
|
self.rejectReadyToSendIceUpdatesPromise = reject
|
|
self.readyToSendIceUpdatesPromise = promise
|
|
}
|
|
|
|
private func waitForPeerConnectionClient() -> Promise<Void> {
|
|
AssertIsOnMainThread()
|
|
|
|
guard self.peerConnectionClient == nil else {
|
|
// peerConnectionClient already set
|
|
return Promise(value: ())
|
|
}
|
|
|
|
if self.peerConnectionClientPromise == nil {
|
|
createPeerConnectionClientPromise()
|
|
}
|
|
|
|
guard let peerConnectionClientPromise = self.peerConnectionClientPromise else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCouldNotCreatePeerConnectionClientPromise(), file: #file, function: #function, line: #line)
|
|
return Promise(error: CallError.assertionError(description: "failed to create peerConnectionClientPromise"))
|
|
}
|
|
|
|
return peerConnectionClientPromise
|
|
}
|
|
|
|
private func createPeerConnectionClientPromise() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard self.peerConnectionClientPromise == nil else {
|
|
owsFail("expected peerConnectionClientPromise to be nil")
|
|
return
|
|
}
|
|
|
|
guard self.fulfillPeerConnectionClientPromise == nil else {
|
|
owsFail("expected fulfillPeerConnectionClientPromise to be nil")
|
|
return
|
|
}
|
|
|
|
guard self.rejectPeerConnectionClientPromise == nil else {
|
|
owsFail("expected rejectPeerConnectionClientPromise to be nil")
|
|
return
|
|
}
|
|
|
|
let (promise, fulfill, reject) = Promise<Void>.pending()
|
|
self.fulfillPeerConnectionClientPromise = fulfill
|
|
self.rejectPeerConnectionClientPromise = reject
|
|
self.peerConnectionClientPromise = promise
|
|
}
|
|
|
|
/**
|
|
* RTCIceServers are used when attempting to establish an optimal connection to the other party. SignalService supplies
|
|
* a list of servers, plus we have fallback servers hardcoded in the app.
|
|
*/
|
|
private func getIceServers() -> Promise<[RTCIceServer]> {
|
|
AssertIsOnMainThread()
|
|
|
|
return firstly {
|
|
return accountManager.getTurnServerInfo()
|
|
}.then { turnServerInfo -> [RTCIceServer] in
|
|
Logger.debug("\(self.logTag) got turn server urls: \(turnServerInfo.urls)")
|
|
|
|
return turnServerInfo.urls.map { url in
|
|
if url.hasPrefix("turn") {
|
|
// Only "turn:" servers require authentication. Don't include the credentials to other ICE servers
|
|
// as 1.) they aren't used, and 2.) the non-turn servers might not be under our control.
|
|
// e.g. we use a public fallback STUN server.
|
|
return RTCIceServer(urlStrings: [url], username: turnServerInfo.username, credential: turnServerInfo.password)
|
|
} else {
|
|
return RTCIceServer(urlStrings: [url])
|
|
}
|
|
} + [CallService.fallbackIceServer]
|
|
}.recover { error -> [RTCIceServer] in
|
|
Logger.error("\(self.logTag) fetching ICE servers failed with error: \(error)")
|
|
Logger.warn("\(self.logTag) using fallback ICE Servers")
|
|
|
|
return [CallService.fallbackIceServer]
|
|
}
|
|
}
|
|
|
|
// This method should be called when either: a) we know or assume that
|
|
// the error is related to the current call. b) the error is so serious
|
|
// that we want to terminate the current call (if any) in order to
|
|
// return to a known good state.
|
|
public func handleFailedCurrentCall(error: CallError) {
|
|
Logger.debug("\(self.logTag) in \(#function)")
|
|
|
|
// Return to a known good state by ending the current call, if any.
|
|
handleFailedCall(failedCall: self.call, error: error)
|
|
}
|
|
|
|
// This method should be called when a fatal error occurred for a call.
|
|
//
|
|
// * If we know which call it was, we should update that call's state
|
|
// to reflect the error.
|
|
// * IFF that call is the current call, we want to terminate it.
|
|
public func handleFailedCall(failedCall: SignalCall?, error: CallError) {
|
|
AssertIsOnMainThread()
|
|
|
|
if case CallError.assertionError(description:let description) = error {
|
|
owsFail(description)
|
|
}
|
|
|
|
if let failedCall = failedCall {
|
|
|
|
if failedCall.state == .answering {
|
|
assert(failedCall.callRecord == nil)
|
|
// call failed before any call record could be created, make one now.
|
|
handleMissedCall(failedCall)
|
|
}
|
|
assert(failedCall.callRecord != nil)
|
|
|
|
// It's essential to set call.state before terminateCall, because terminateCall nils self.call
|
|
failedCall.error = error
|
|
failedCall.state = .localFailure
|
|
self.callUIAdapter.failCall(failedCall, error: error)
|
|
|
|
// Only terminate the current call if the error pertains to the current call.
|
|
guard failedCall == self.call else {
|
|
Logger.debug("\(self.logTag) in \(#function) ignoring obsolete call: \(failedCall.identifiersForLogs).")
|
|
return
|
|
}
|
|
|
|
Logger.error("\(self.logTag) call: \(failedCall.identifiersForLogs) failed with error: \(error)")
|
|
} else {
|
|
Logger.error("\(self.logTag) unknown call failed with error: \(error)")
|
|
}
|
|
|
|
// Only terminate the call if it is the current call.
|
|
terminateCall()
|
|
}
|
|
|
|
/**
|
|
* Clean up any existing call state and get ready to receive a new call.
|
|
*/
|
|
private func terminateCall() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("\(self.logTag) in \(#function)")
|
|
|
|
self.localVideoTrack = nil
|
|
self.remoteVideoTrack = nil
|
|
self.isRemoteVideoEnabled = false
|
|
|
|
self.peerConnectionClient?.terminate()
|
|
Logger.debug("\(self.logTag) setting peerConnectionClient in \(#function)")
|
|
self.peerConnectionClient = nil
|
|
|
|
self.call?.removeAllObservers()
|
|
self.callUIAdapter.didTerminateCall(self.call)
|
|
self.call = nil
|
|
|
|
self.fulfillCallConnectedPromise = nil
|
|
|
|
// In case we're still waiting on this promise somewhere, we need to reject it to avoid a memory leak.
|
|
// There is no harm in rejecting a previously fulfilled promise.
|
|
if let rejectCallConnectedPromise = self.rejectCallConnectedPromise {
|
|
rejectCallConnectedPromise(CallError.obsoleteCall(description: "Terminating call"))
|
|
}
|
|
self.rejectCallConnectedPromise = nil
|
|
|
|
// In case we're still waiting on the peer connection setup somewhere, we need to reject it to avoid a memory leak.
|
|
// There is no harm in rejecting a previously fulfilled promise.
|
|
if let rejectPeerConnectionClientPromise = self.rejectPeerConnectionClientPromise {
|
|
rejectPeerConnectionClientPromise(CallError.obsoleteCall(description: "Terminating call"))
|
|
}
|
|
self.rejectPeerConnectionClientPromise = nil
|
|
self.fulfillPeerConnectionClientPromise = nil
|
|
self.peerConnectionClientPromise = nil
|
|
|
|
// In case we're still waiting on this promise somewhere, we need to reject it to avoid a memory leak.
|
|
// There is no harm in rejecting a previously fulfilled promise.
|
|
if let rejectReadyToSendIceUpdatesPromise = self.rejectReadyToSendIceUpdatesPromise {
|
|
rejectReadyToSendIceUpdatesPromise(CallError.obsoleteCall(description: "Terminating call"))
|
|
}
|
|
self.fulfillReadyToSendIceUpdatesPromise = nil
|
|
self.rejectReadyToSendIceUpdatesPromise = nil
|
|
self.readyToSendIceUpdatesPromise = nil
|
|
}
|
|
|
|
// MARK: - CallObserver
|
|
|
|
internal func stateDidChange(call: SignalCall, state: CallState) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("\(self.logTag) \(#function): \(state)")
|
|
updateIsVideoEnabled()
|
|
}
|
|
|
|
internal func hasLocalVideoDidChange(call: SignalCall, hasLocalVideo: Bool) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("\(self.logTag) \(#function): \(hasLocalVideo)")
|
|
self.updateIsVideoEnabled()
|
|
}
|
|
|
|
internal func muteDidChange(call: SignalCall, isMuted: Bool) {
|
|
AssertIsOnMainThread()
|
|
// Do nothing
|
|
}
|
|
|
|
internal func holdDidChange(call: SignalCall, isOnHold: Bool) {
|
|
AssertIsOnMainThread()
|
|
// Do nothing
|
|
}
|
|
|
|
internal func audioSourceDidChange(call: SignalCall, audioSource: AudioSource?) {
|
|
AssertIsOnMainThread()
|
|
// Do nothing
|
|
}
|
|
|
|
// MARK: - Video
|
|
|
|
private func shouldHaveLocalVideoTrack() -> Bool {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
return false
|
|
}
|
|
|
|
// The iOS simulator doesn't provide any sort of camera capture
|
|
// support or emulation (http://goo.gl/rHAnC1) so don't bother
|
|
// trying to open a local stream.
|
|
return (!Platform.isSimulator &&
|
|
UIApplication.shared.applicationState != .background &&
|
|
call.state == .connected &&
|
|
call.hasLocalVideo)
|
|
}
|
|
|
|
//TODO only fire this when it's changed? as of right now it gets called whenever you e.g. lock the phone while it's incoming ringing.
|
|
private func updateIsVideoEnabled() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let call = self.call else {
|
|
return
|
|
}
|
|
guard let peerConnectionClient = self.peerConnectionClient else {
|
|
return
|
|
}
|
|
|
|
let shouldHaveLocalVideoTrack = self.shouldHaveLocalVideoTrack()
|
|
|
|
Logger.info("\(self.logTag) \(#function): \(shouldHaveLocalVideoTrack)")
|
|
|
|
self.peerConnectionClient?.setLocalVideoEnabled(enabled: shouldHaveLocalVideoTrack)
|
|
|
|
let message = DataChannelMessage.forVideoStreamingStatus(callId: call.signalingId, enabled: shouldHaveLocalVideoTrack)
|
|
peerConnectionClient.sendDataChannelMessage(data: message.asData(), description: "videoStreamingStatus", isCritical: false)
|
|
}
|
|
|
|
// MARK: - Observers
|
|
|
|
// The observer-related methods should be invoked on the main thread.
|
|
func addObserverAndSyncState(observer: CallServiceObserver) {
|
|
AssertIsOnMainThread()
|
|
|
|
observers.append(Weak(value: observer))
|
|
|
|
// Synchronize observer with current call state
|
|
let call = self.call
|
|
let localVideoTrack = self.localVideoTrack
|
|
let remoteVideoTrack = self.isRemoteVideoEnabled ? self.remoteVideoTrack : nil
|
|
observer.didUpdateVideoTracks(call: call,
|
|
localVideoTrack: localVideoTrack,
|
|
remoteVideoTrack: remoteVideoTrack)
|
|
}
|
|
|
|
// The observer-related methods should be invoked on the main thread.
|
|
func removeObserver(_ observer: CallServiceObserver) {
|
|
AssertIsOnMainThread()
|
|
|
|
while let index = observers.index(where: { $0.value === observer }) {
|
|
observers.remove(at: index)
|
|
}
|
|
}
|
|
|
|
// The observer-related methods should be invoked on the main thread.
|
|
func removeAllObservers() {
|
|
AssertIsOnMainThread()
|
|
|
|
observers = []
|
|
}
|
|
|
|
private func fireDidUpdateVideoTracks() {
|
|
AssertIsOnMainThread()
|
|
|
|
let call = self.call
|
|
let localVideoTrack = self.localVideoTrack
|
|
let remoteVideoTrack = self.isRemoteVideoEnabled ? self.remoteVideoTrack : nil
|
|
|
|
for observer in observers {
|
|
observer.value?.didUpdateVideoTracks(call: call,
|
|
localVideoTrack: localVideoTrack,
|
|
remoteVideoTrack: remoteVideoTrack)
|
|
}
|
|
}
|
|
|
|
// MARK: CallViewController Timer
|
|
|
|
var activeCallTimer: Timer?
|
|
func startCallTimer() {
|
|
AssertIsOnMainThread()
|
|
|
|
if self.activeCallTimer != nil {
|
|
owsFail("\(self.logTag) activeCallTimer should only be set once per call")
|
|
self.activeCallTimer!.invalidate()
|
|
self.activeCallTimer = nil
|
|
}
|
|
|
|
self.activeCallTimer = WeakTimer.scheduledTimer(timeInterval: 1, target: self, userInfo: nil, repeats: true) { [weak self] timer in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
guard let call = strongSelf.call else {
|
|
owsFail("\(strongSelf.logTag) call has since ended. Timer should have been invalidated.")
|
|
timer.invalidate()
|
|
return
|
|
}
|
|
|
|
strongSelf.ensureCallScreenPresented(call: call)
|
|
}
|
|
}
|
|
|
|
func ensureCallScreenPresented(call: SignalCall) {
|
|
guard let connectedDate = call.connectedDate else {
|
|
// Ignore; call hasn't connected yet.
|
|
return
|
|
}
|
|
|
|
let kMaxViewPresentationDelay = 2.5
|
|
guard fabs(connectedDate.timeIntervalSinceNow) > kMaxViewPresentationDelay else {
|
|
// Ignore; call connected recently.
|
|
return
|
|
}
|
|
|
|
let frontmostViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts
|
|
|
|
guard nil != frontmostViewController as? CallViewController else {
|
|
OWSProdError(OWSAnalyticsEvents.callServiceCallViewCouldNotPresent(), file: #file, function: #function, line: #line)
|
|
owsFail("\(self.logTag) in \(#function) Call terminated due to call view presentation delay: \(frontmostViewController.debugDescription).")
|
|
self.terminateCall()
|
|
return
|
|
}
|
|
}
|
|
|
|
func stopAnyCallTimer() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.activeCallTimer?.invalidate()
|
|
self.activeCallTimer = nil
|
|
}
|
|
}
|