mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1358 lines
53 KiB
Swift
1358 lines
53 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import PromiseKit
|
|
import SignalRingRTC
|
|
import WebRTC
|
|
import SessionMessagingKit
|
|
|
|
// MARK: - CallService
|
|
|
|
// This class' state should only be accessed on the main queue.
|
|
@objc final public class IndividualCallService: NSObject {
|
|
|
|
private var callManager: CallService.CallManagerType {
|
|
return callService.callManager
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
// Exposed by environment.m
|
|
|
|
@objc public var callUIAdapter: CallUIAdapter!
|
|
|
|
// MARK: Class
|
|
|
|
static let fallbackIceServer = RTCIceServer(urlStrings: ["stun:stun1.l.google.com:19302"])
|
|
|
|
@objc public override init() {
|
|
super.init()
|
|
|
|
SwiftSingletons.register(self)
|
|
}
|
|
|
|
/**
|
|
* Choose whether to use CallKit or a Notification backed interface for calling.
|
|
*/
|
|
@objc public func createCallUIAdapter() {
|
|
AssertIsOnMainThread()
|
|
|
|
if let call = callService.currentCall {
|
|
Logger.warn("ending current call in. Did user toggle callkit preference while in a call?")
|
|
callService.terminate(call: call)
|
|
}
|
|
|
|
self.callUIAdapter = CallUIAdapter()
|
|
}
|
|
|
|
// MARK: - Call Control Actions
|
|
|
|
/**
|
|
* Initiate an outgoing call.
|
|
*/
|
|
func handleOutgoingCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("call: \(call)")
|
|
|
|
BenchEventStart(title: "Outgoing Call Connection", eventId: "call-\(call.individualCall.localId)")
|
|
|
|
guard callService.currentCall == nil else {
|
|
owsFailDebug("call already exists: \(String(describing: callService.currentCall))")
|
|
return
|
|
}
|
|
|
|
// Create a callRecord for outgoing calls immediately.
|
|
let callRecord = TSCall(
|
|
callType: .outgoingIncomplete,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
call.individualCall.callRecord = callRecord
|
|
|
|
do {
|
|
try callManager.placeCall(call: call, callMediaType: call.individualCall.offerMediaType.asCallMediaType, localDevice: 1)
|
|
} catch {
|
|
self.handleFailedCall(failedCall: call, error: error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* User chose to answer the call. Used by the Callee only.
|
|
*/
|
|
public func handleAcceptCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("\(call)")
|
|
|
|
guard callService.currentCall === call else {
|
|
let error = OWSAssertionError("accepting call: \(call) which is different from currentCall: \(callService.currentCall as Optional)")
|
|
handleFailedCall(failedCall: call, error: error)
|
|
return
|
|
}
|
|
|
|
guard let callId = call.individualCall.callId else {
|
|
handleFailedCall(failedCall: call, error: OWSAssertionError("no callId for call: \(call)"))
|
|
return
|
|
}
|
|
|
|
let callRecord = TSCall(
|
|
callType: .incomingIncomplete,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
call.individualCall.callRecord = callRecord
|
|
|
|
do {
|
|
try callManager.accept(callId: callId)
|
|
|
|
// It's key that we configure the AVAudioSession for a call *before* we fulfill the
|
|
// CXAnswerCallAction.
|
|
//
|
|
// Otherwise CallKit has been seen not to activate the audio session.
|
|
// That is, `provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession)`
|
|
// was sometimes not called.`
|
|
//
|
|
// That is why we connect here, rather than waiting for a racy async response from CallManager,
|
|
// confirming that the call has connected.
|
|
handleConnected(call: call)
|
|
} catch {
|
|
self.handleFailedCall(failedCall: call, error: error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Local user chose to end the call.
|
|
*/
|
|
func handleLocalHangupCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("\(call)")
|
|
|
|
guard call === callService.currentCall else {
|
|
Logger.info("ignoring hangup for obsolete call: \(call)")
|
|
return
|
|
}
|
|
|
|
if let callRecord = call.individualCall.callRecord {
|
|
if callRecord.callType == .outgoingIncomplete {
|
|
callRecord.updateCallType(.outgoingMissed)
|
|
}
|
|
} else if call.individualCall.state == .localRinging {
|
|
let callRecord = TSCall(
|
|
callType: .incomingDeclined,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
call.individualCall.callRecord = callRecord
|
|
} else {
|
|
owsFailDebug("missing call record")
|
|
}
|
|
|
|
call.individualCall.state = .localHangup
|
|
|
|
ensureAudioState(call: call)
|
|
|
|
callService.terminate(call: call)
|
|
|
|
do {
|
|
try callManager.hangup()
|
|
} catch {
|
|
// no point in "failing" the call if the user expressed their intent to hang up
|
|
// and we've already called: `terminate(call: cal)`
|
|
owsFailDebug("error: \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Signaling Functions
|
|
|
|
private func allowsInboundCallsInThread(_ thread: TSContactThread) -> Bool {
|
|
// TODO: We might want to add some conditions here, like whether people have messaged
|
|
// eachother, whether the contact is blocked, etc.
|
|
return true
|
|
}
|
|
|
|
private struct CallIdentityKeys {
|
|
let localIdentityKey: Data
|
|
let contactIdentityKey: Data
|
|
}
|
|
|
|
private func getIdentityKeys(thread: TSContactThread) -> CallIdentityKeys? {
|
|
let identityManager = OWSIdentityManager.shared()
|
|
guard let localIdentityKey = identityManager.identityKeyPair()?.publicKey else {
|
|
owsFailDebug("missing localIdentityKey")
|
|
return nil
|
|
}
|
|
guard let contactIdentityKey = identityManager.recipientIdentity(forRecipientId: thread.contactSessionID())?.identityKey else {
|
|
owsFailDebug("Looks like we're not actually maintaining the identity key for contacts. How will we fix that given that CallIdentityKeys wants this?")
|
|
return nil
|
|
}
|
|
return CallIdentityKeys(localIdentityKey: localIdentityKey, contactIdentityKey: contactIdentityKey)
|
|
}
|
|
|
|
/**
|
|
* Received an incoming call Offer from call initiator.
|
|
*/
|
|
public func handleReceivedOffer(
|
|
thread: TSContactThread,
|
|
callId: UInt64,
|
|
sourceDevice: UInt32,
|
|
sdp: String?,
|
|
opaque: Data?,
|
|
sentAtTimestamp: UInt64,
|
|
serverReceivedTimestamp: UInt64,
|
|
serverDeliveryTimestamp: UInt64,
|
|
callType: SNProtoCallMessageOfferType,
|
|
supportsMultiRing: Bool
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
// opaque is required. sdp is obsolete, but it might still come with opaque.
|
|
guard let opaque = opaque else {
|
|
// TODO: Remove once the proto is updated to only support opaque and require it.
|
|
Logger.debug("opaque not received for offer, remote should update")
|
|
return
|
|
}
|
|
|
|
let newCall = callService.prepareIncomingIndividualCall(
|
|
thread: thread,
|
|
sentAtTimestamp: sentAtTimestamp,
|
|
callType: callType
|
|
)
|
|
|
|
BenchEventStart(title: "Incoming Call Connection", eventId: "call-\(newCall.individualCall.localId)")
|
|
|
|
/*
|
|
* We might not need the below code, since we don't have the concept of untrusted identities
|
|
if let untrustedIdentity = self.identityManager.untrustedIdentityForSending(to: thread.contactAddress) {
|
|
Logger.warn("missed a call due to untrusted identity: \(newCall)")
|
|
|
|
let callerName = self.contactsManager.displayName(for: thread.contactAddress)
|
|
|
|
let notificationPresenter = AppEnvironment.shared.notificationPresenter
|
|
switch untrustedIdentity.verificationState {
|
|
case .verified:
|
|
owsFailDebug("shouldn't have missed a call due to untrusted identity if the identity is verified")
|
|
notificationPresenter.presentMissedCall(newCall.individualCall, callerName: callerName)
|
|
case .default:
|
|
notificationPresenter.presentMissedCallBecauseOfNewIdentity(call: newCall.individualCall, callerName: callerName)
|
|
case .noLongerVerified:
|
|
notificationPresenter.presentMissedCallBecauseOfNoLongerVerifiedIdentity(call: newCall.individualCall, callerName: callerName)
|
|
}
|
|
|
|
let callRecord = TSCall(
|
|
callType: .incomingMissedBecauseOfChangedIdentity,
|
|
offerType: newCall.individualCall.offerMediaType,
|
|
thread: thread,
|
|
sentAtTimestamp: sentAtTimestamp
|
|
)
|
|
assert(newCall.individualCall.callRecord == nil)
|
|
newCall.individualCall.callRecord = callRecord
|
|
databaseStorage.asyncWrite { transaction in
|
|
callRecord.anyInsert(transaction: transaction)
|
|
}
|
|
|
|
newCall.individualCall.state = .localFailure
|
|
callService.terminate(call: newCall)
|
|
|
|
return
|
|
}
|
|
*/
|
|
|
|
guard let identityKeys = getIdentityKeys(thread: thread) else {
|
|
owsFailDebug("missing identity keys, skipping call.")
|
|
let callRecord = TSCall(
|
|
callType: .incomingMissed,
|
|
offerType: newCall.individualCall.offerMediaType,
|
|
thread: thread,
|
|
sentAtTimestamp: sentAtTimestamp
|
|
)
|
|
assert(newCall.individualCall.callRecord == nil)
|
|
newCall.individualCall.callRecord = callRecord
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
|
|
newCall.individualCall.state = .localFailure
|
|
callService.terminate(call: newCall)
|
|
|
|
return
|
|
}
|
|
|
|
guard allowsInboundCallsInThread(thread) else {
|
|
Logger.info("Ignoring call offer from \(thread.contactSessionID()) due to insufficient permissions.")
|
|
|
|
// Send the need permission message to the caller, so they know why we rejected their call.
|
|
callManager(
|
|
callManager,
|
|
shouldSendHangup: callId,
|
|
call: newCall,
|
|
destinationDeviceId: sourceDevice,
|
|
hangupType: .needPermission,
|
|
deviceId: 1,
|
|
useLegacyHangupMessage: true
|
|
)
|
|
|
|
// Store the call as a missed call for the local user. They will see it in the conversation
|
|
// along with the message request dialog. When they accept the dialog, they can call back
|
|
// or the caller can try again.
|
|
let callRecord = TSCall(
|
|
callType: .incomingMissed,
|
|
offerType: newCall.individualCall.offerMediaType,
|
|
thread: thread,
|
|
sentAtTimestamp: sentAtTimestamp
|
|
)
|
|
assert(newCall.individualCall.callRecord == nil)
|
|
newCall.individualCall.callRecord = callRecord
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
|
|
newCall.individualCall.state = .localFailure
|
|
callService.terminate(call: newCall)
|
|
|
|
return
|
|
}
|
|
|
|
Logger.debug("Enable backgroundTask")
|
|
let backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "\(#function)", completionBlock: { status in
|
|
AssertIsOnMainThread()
|
|
|
|
guard status == .expired else {
|
|
return
|
|
}
|
|
|
|
// See if the newCall actually became the currentCall.
|
|
guard case .individual(let currentCall) = self.callService.currentCall?.mode,
|
|
newCall === currentCall else {
|
|
Logger.warn("ignoring obsolete call")
|
|
return
|
|
}
|
|
|
|
self.handleFailedCall(failedCall: newCall, error: SignalCall.CallError.timeout(description: "background task time ran out before call connected"))
|
|
})
|
|
|
|
newCall.individualCall.backgroundTask = backgroundTask
|
|
|
|
var messageAgeSec: UInt64 = 0
|
|
if serverReceivedTimestamp > 0 && serverDeliveryTimestamp >= serverReceivedTimestamp {
|
|
messageAgeSec = (serverDeliveryTimestamp - serverReceivedTimestamp) / 1000
|
|
}
|
|
|
|
do {
|
|
try callManager.receivedOffer(call: newCall, sourceDevice: sourceDevice, callId: callId, opaque: opaque, messageAgeSec: messageAgeSec, callMediaType: newCall.individualCall.offerMediaType.asCallMediaType, localDevice: 1, remoteSupportsMultiRing: supportsMultiRing, isLocalDevicePrimary: true, senderIdentityKey: identityKeys.contactIdentityKey, receiverIdentityKey: identityKeys.localIdentityKey)
|
|
} catch {
|
|
handleFailedCall(failedCall: newCall, error: error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called by the call initiator after receiving an Answer from the callee.
|
|
*/
|
|
public func handleReceivedAnswer(thread: TSContactThread, callId: UInt64, sourceDevice: UInt32, sdp: String?, opaque: Data?, supportsMultiRing: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
// opaque is required. sdp is obsolete, but it might still come with opaque.
|
|
guard let opaque = opaque else {
|
|
// TODO: Remove once the proto is updated to only support opaque and require it.
|
|
Logger.debug("opaque not received for answer, remote should update")
|
|
return
|
|
}
|
|
|
|
guard let identityKeys = getIdentityKeys(thread: thread) else {
|
|
if let currentCall = callService.currentCall, currentCall.individualCall?.callId == callId {
|
|
handleFailedCall(failedCall: currentCall, error: OWSAssertionError("missing identity keys"))
|
|
}
|
|
return
|
|
}
|
|
|
|
do {
|
|
try callManager.receivedAnswer(sourceDevice: sourceDevice, callId: callId, opaque: opaque, remoteSupportsMultiRing: supportsMultiRing, senderIdentityKey: identityKeys.contactIdentityKey, receiverIdentityKey: identityKeys.localIdentityKey)
|
|
} catch {
|
|
owsFailDebug("error: \(error)")
|
|
if let currentCall = callService.currentCall, currentCall.individualCall?.callId == callId {
|
|
handleFailedCall(failedCall: currentCall, error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remote client (could be caller or callee) sent us a connectivity update.
|
|
*/
|
|
public func handleReceivedIceCandidates(thread: TSContactThread, callId: UInt64, sourceDevice: UInt32, candidates: [SNProtoCallMessageIceUpdate]) {
|
|
AssertIsOnMainThread()
|
|
|
|
let iceCandidates = candidates.filter { $0.id == callId && $0.opaque != nil }.map { $0.opaque! }
|
|
|
|
guard iceCandidates.count > 0 else {
|
|
Logger.debug("no ice candidates in ice message, remote should update")
|
|
return
|
|
}
|
|
|
|
do {
|
|
try callManager.receivedIceCandidates(sourceDevice: sourceDevice, callId: callId, candidates: iceCandidates)
|
|
} catch {
|
|
owsFailDebug("error: \(error)")
|
|
// we don't necessarily want to fail the call just because CallManager errored on an
|
|
// ICE candidate
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The remote client (caller or callee) ended the call.
|
|
*/
|
|
public func handleReceivedHangup(thread: TSContactThread, callId: UInt64, sourceDevice: UInt32, type: SNProtoCallMessageHangupType, deviceId: UInt32) {
|
|
AssertIsOnMainThread()
|
|
|
|
let hangupType: HangupType
|
|
switch type {
|
|
case .hangupNormal: hangupType = .normal
|
|
case .hangupAccepted: hangupType = .accepted
|
|
case .hangupDeclined: hangupType = .declined
|
|
case .hangupBusy: hangupType = .busy
|
|
case .hangupNeedPermission: hangupType = .needPermission
|
|
}
|
|
|
|
do {
|
|
try callManager.receivedHangup(sourceDevice: sourceDevice, callId: callId, hangupType: hangupType, deviceId: deviceId)
|
|
} catch {
|
|
owsFailDebug("\(error)")
|
|
if let currentCall = callService.currentCall, currentCall.individualCall?.callId == callId {
|
|
handleFailedCall(failedCall: currentCall, error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The callee was already in another call.
|
|
*/
|
|
public func handleReceivedBusy(thread: TSContactThread, callId: UInt64, sourceDevice: UInt32) {
|
|
AssertIsOnMainThread()
|
|
|
|
do {
|
|
try callManager.receivedBusy(sourceDevice: sourceDevice, callId: callId)
|
|
} catch {
|
|
owsFailDebug("\(error)")
|
|
if let currentCall = callService.currentCall, currentCall.individualCall?.callId == callId {
|
|
handleFailedCall(failedCall: currentCall, error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Call Manager Events
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, shouldStartCall call: SignalCall, callId: UInt64, isOutgoing: Bool, callMediaType: CallMediaType) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("call: \(call)")
|
|
|
|
// Start the call, asynchronously.
|
|
getIceServers().done { iceServers in
|
|
guard self.callService.currentCall === call else {
|
|
Logger.debug("call has since ended")
|
|
return
|
|
}
|
|
|
|
var isUnknownCaller = false
|
|
if call.individualCall.direction == .incoming {
|
|
isUnknownCaller = !Storage.shared.getAllContacts().contains { $0.sessionID == call.individualCall.thread.contactSessionID() }
|
|
if isUnknownCaller {
|
|
Logger.warn("Using relay server because remote user is an unknown caller")
|
|
}
|
|
}
|
|
|
|
let useTurnOnly = isUnknownCaller
|
|
|
|
let useLowBandwidth = CallService.useLowBandwidthWithSneakyTransaction()
|
|
Logger.info("Configuring call for \(useLowBandwidth ? "low" : "standard") bandwidth")
|
|
|
|
// Tell the Call Manager to proceed with its active call.
|
|
try self.callManager.proceed(callId: callId, iceServers: iceServers, hideIp: useTurnOnly, videoCaptureController: call.videoCaptureController, bandwidthMode: useLowBandwidth ? .low : .normal)
|
|
}.catch { error in
|
|
owsFailDebug("\(error)")
|
|
guard call === self.callService.currentCall else {
|
|
Logger.debug("")
|
|
return
|
|
}
|
|
|
|
callManager.drop(callId: callId)
|
|
self.handleFailedCall(failedCall: call, error: error)
|
|
}
|
|
|
|
Logger.debug("")
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, onEvent call: SignalCall, event: CallManagerEvent) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("call: \(call), onEvent: \(event)")
|
|
|
|
switch event {
|
|
case .ringingLocal:
|
|
handleRinging(call: call)
|
|
|
|
case .ringingRemote:
|
|
handleRinging(call: call)
|
|
|
|
case .connectedLocal:
|
|
Logger.debug("")
|
|
// nothing further to do - already handled in handleAcceptCall().
|
|
|
|
case .connectedRemote:
|
|
callUIAdapter.recipientAcceptedCall(call)
|
|
handleConnected(call: call)
|
|
|
|
case .endedLocalHangup:
|
|
Logger.debug("")
|
|
// nothing further to do - already handled in handleLocalHangupCall().
|
|
|
|
case .endedRemoteHangup:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .idle, .dialing, .answering, .localRinging, .localFailure, .remoteBusy, .remoteRinging:
|
|
handleMissedCall(call)
|
|
case .connected, .reconnecting, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .answeredElsewhere, .declinedElsewhere, .busyElsewhere:
|
|
Logger.info("call is finished")
|
|
}
|
|
|
|
call.individualCall.state = .remoteHangup
|
|
|
|
// Notify UI
|
|
callUIAdapter.remoteDidHangupCall(call)
|
|
|
|
callService.terminate(call: call)
|
|
|
|
case .endedRemoteHangupNeedPermission:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .idle, .dialing, .answering, .localRinging, .localFailure, .remoteBusy, .remoteRinging:
|
|
handleMissedCall(call)
|
|
case .connected, .reconnecting, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .answeredElsewhere, .declinedElsewhere, .busyElsewhere:
|
|
Logger.info("call is finished")
|
|
}
|
|
|
|
call.individualCall.state = .remoteHangupNeedPermission
|
|
|
|
// Notify UI
|
|
callUIAdapter.remoteDidHangupCall(call)
|
|
|
|
callService.terminate(call: call)
|
|
|
|
case .endedRemoteHangupAccepted:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .idle, .dialing, .remoteBusy, .remoteRinging, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .remoteHangup, .remoteHangupNeedPermission:
|
|
handleFailedCall(failedCall: call, error: OWSAssertionError("unexpected state for endedRemoteHangupAccepted: \(call.individualCall.state)"))
|
|
return
|
|
case .answering, .connected:
|
|
Logger.info("tried answering locally, but answered somewhere else first. state: \(call.individualCall.state)")
|
|
handleAnsweredElsewhere(call: call)
|
|
case .localRinging, .reconnecting:
|
|
handleAnsweredElsewhere(call: call)
|
|
case .localFailure, .localHangup:
|
|
Logger.info("ignoring 'endedRemoteHangupAccepted' since call is already finished")
|
|
}
|
|
|
|
case .endedRemoteHangupDeclined:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .idle, .dialing, .remoteBusy, .remoteRinging, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .remoteHangup, .remoteHangupNeedPermission:
|
|
handleFailedCall(failedCall: call, error: OWSAssertionError("unexpected state for endedRemoteHangupDeclined: \(call.individualCall.state)"))
|
|
return
|
|
case .answering, .connected:
|
|
Logger.info("tried answering locally, but declined somewhere else first. state: \(call.individualCall.state)")
|
|
handleDeclinedElsewhere(call: call)
|
|
case .localRinging, .reconnecting:
|
|
handleDeclinedElsewhere(call: call)
|
|
case .localFailure, .localHangup:
|
|
Logger.info("ignoring 'endedRemoteHangupDeclined' since call is already finished")
|
|
}
|
|
|
|
case .endedRemoteHangupBusy:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .idle, .dialing, .remoteBusy, .remoteRinging, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .remoteHangup, .remoteHangupNeedPermission:
|
|
handleFailedCall(failedCall: call, error: OWSAssertionError("unexpected state for endedRemoteHangupBusy: \(call.individualCall.state)"))
|
|
return
|
|
case .answering, .connected:
|
|
Logger.info("tried answering locally, but already in a call somewhere else first. state: \(call.individualCall.state)")
|
|
handleBusyElsewhere(call: call)
|
|
case .localRinging, .reconnecting:
|
|
handleBusyElsewhere(call: call)
|
|
case .localFailure, .localHangup:
|
|
Logger.info("ignoring 'endedRemoteHangupBusy' since call is already finished")
|
|
}
|
|
|
|
case .endedRemoteBusy:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
assert(call.individualCall.direction == .outgoing)
|
|
if let callRecord = call.individualCall.callRecord {
|
|
callRecord.updateCallType(.outgoingMissed)
|
|
} else {
|
|
owsFailDebug("outgoing call should have call record")
|
|
}
|
|
|
|
call.individualCall.state = .remoteBusy
|
|
|
|
// Notify UI
|
|
callUIAdapter.remoteBusy(call)
|
|
|
|
callService.terminate(call: call)
|
|
|
|
case .endedRemoteGlare:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
if let callRecord = call.individualCall.callRecord {
|
|
switch callRecord.callType {
|
|
case .outgoingMissed, .incomingDeclined, .incomingMissed, .incomingMissedBecauseOfChangedIdentity, .incomingAnsweredElsewhere, .incomingDeclinedElsewhere, .incomingBusyElsewhere:
|
|
// already handled and ended, don't update the call record.
|
|
break
|
|
case .incomingIncomplete, .incoming:
|
|
callRecord.updateCallType(.incomingMissed)
|
|
callUIAdapter.reportMissedCall(call)
|
|
case .outgoingIncomplete:
|
|
callRecord.updateCallType(.outgoingMissed)
|
|
callUIAdapter.remoteBusy(call)
|
|
case .outgoing:
|
|
callRecord.updateCallType(.outgoingMissed)
|
|
callUIAdapter.reportMissedCall(call)
|
|
@unknown default:
|
|
owsFailDebug("unknown RPRecentCallType: \(callRecord.callType)")
|
|
}
|
|
} else {
|
|
assert(call.individualCall.direction == .incoming)
|
|
let callRecord = TSCall(
|
|
callType: .incomingMissed,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
call.individualCall.callRecord = callRecord
|
|
callUIAdapter.reportMissedCall(call)
|
|
}
|
|
call.individualCall.state = .localFailure
|
|
callService.terminate(call: call)
|
|
|
|
case .endedTimeout:
|
|
let description: String
|
|
|
|
if call.individualCall.direction == .outgoing {
|
|
description = "timeout for outgoing call"
|
|
} else {
|
|
description = "timeout for incoming call"
|
|
}
|
|
|
|
handleFailedCall(failedCall: call, error: SignalCall.CallError.timeout(description: description))
|
|
|
|
case .endedSignalingFailure:
|
|
handleFailedCall(failedCall: call, error: SignalCall.CallError.timeout(description: "signaling failure for call"))
|
|
|
|
case .endedInternalFailure:
|
|
handleFailedCall(failedCall: call, error: OWSAssertionError("call manager internal error"))
|
|
|
|
case .endedConnectionFailure:
|
|
handleFailedCall(failedCall: call, error: SignalCall.CallError.disconnected)
|
|
|
|
case .endedDropped:
|
|
Logger.debug("")
|
|
|
|
// An incoming call was dropped, ignoring because we have already
|
|
// failed the call on the screen.
|
|
|
|
case .remoteVideoEnable:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
call.individualCall.isRemoteVideoEnabled = true
|
|
|
|
case .remoteVideoDisable:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
call.individualCall.isRemoteVideoEnabled = false
|
|
|
|
case .remoteSharingScreenEnable:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
call.individualCall.isRemoteSharingScreen = true
|
|
|
|
case .remoteSharingScreenDisable:
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
call.individualCall.isRemoteSharingScreen = false
|
|
|
|
case .reconnecting:
|
|
self.handleReconnecting(call: call)
|
|
|
|
case .reconnected:
|
|
self.handleReconnected(call: call)
|
|
|
|
case .receivedOfferExpired:
|
|
// TODO - This is the case where an incoming offer's timestamp is
|
|
// not within the range +/- 120 seconds of the current system time.
|
|
// At the moment, this is not an issue since we are currently setting
|
|
// the timestamp separately when we receive the offer (above).
|
|
// This should not be a failure, it is just an 'old' call.
|
|
handleMissedCall(call)
|
|
call.individualCall.state = .localFailure
|
|
callService.terminate(call: call)
|
|
|
|
case .receivedOfferWhileActive:
|
|
handleMissedCall(call)
|
|
// TODO - This should not be a failure.
|
|
call.individualCall.state = .localFailure
|
|
callService.terminate(call: call)
|
|
|
|
case .receivedOfferWithGlare:
|
|
handleMissedCall(call)
|
|
// TODO - This should not be a failure.
|
|
call.individualCall.state = .localFailure
|
|
callService.terminate(call: call)
|
|
|
|
case .ignoreCallsFromNonMultiringCallers:
|
|
handleMissedCall(call)
|
|
call.individualCall.state = .localFailure
|
|
callService.terminate(call: call)
|
|
}
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, onUpdateLocalVideoSession call: SignalCall, session: AVCaptureSession?) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("onUpdateLocalVideoSession")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, onAddRemoteVideoTrack call: SignalCall, track: RTCVideoTrack) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("onAddRemoteVideoTrack")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
call.individualCall.remoteVideoTrack = track
|
|
}
|
|
|
|
// MARK: - Call Manager Signaling
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, shouldSendOffer callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, opaque: Data, callMediaType: CallMediaType) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
|
|
Logger.info("shouldSendOffer")
|
|
|
|
firstly { () throws -> Promise<Void> in
|
|
let message = IndividualCallMessage()
|
|
message.callID = callId
|
|
switch callMediaType {
|
|
case .audioCall: message.kind = .offer(opaque: opaque, callType: .audio)
|
|
case .videoCall: message.kind = .offer(opaque: opaque, callType: .video)
|
|
}
|
|
Storage.write { transaction in
|
|
MessageSender.send(message, in: call.individualCall.thread, using: transaction)
|
|
}
|
|
}.done {
|
|
Logger.info("sent offer message to \(call.individualCall.thread.contactSessionID()) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
|
|
try self.callManager.signalingMessageDidSend(callId: callId)
|
|
}.catch { error in
|
|
Logger.error("failed to send offer message to \(call.individualCall.thread.contactSessionID()) with error: \(error)")
|
|
self.callManager.signalingMessageDidFail(callId: callId)
|
|
}
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, shouldSendAnswer callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, opaque: Data) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("shouldSendAnswer")
|
|
|
|
firstly { () throws -> Promise<Void> in
|
|
let message = IndividualCallMessage()
|
|
message.callID = callId
|
|
message.kind = .answer(opaque: opaque)
|
|
Storage.write { transaction in
|
|
MessageSender.send(message, in: call.individualCall.thread, using: transaction)
|
|
}
|
|
}.done {
|
|
Logger.debug("sent answer message to \(call.individualCall.thread.contactSessionID()) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
|
|
try self.callManager.signalingMessageDidSend(callId: callId)
|
|
}.catch { error in
|
|
Logger.error("failed to send answer message to \(call.individualCall.thread.contactSessionID()) with error: \(error)")
|
|
self.callManager.signalingMessageDidFail(callId: callId)
|
|
}
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, shouldSendIceCandidates callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, candidates: [Data]) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("shouldSendIceCandidates")
|
|
|
|
guard !candidates.isEmpty else {
|
|
Logger.error("no ice updates to send")
|
|
return callManager.signalingMessageDidFail(callId: callId)
|
|
}
|
|
|
|
firstly { () throws -> Promise<Void> in
|
|
let message = IndividualCallMessage()
|
|
message.callID = callId
|
|
message.kind = .iceUpdate(candidates: candidates)
|
|
Storage.write { transaction in
|
|
MessageSender.send(message, in: call.individualCall.thread, using: transaction)
|
|
}
|
|
}.done {
|
|
Logger.debug("sent ice update message to \(call.individualCall.thread.contactSessionID()) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
|
|
try self.callManager.signalingMessageDidSend(callId: callId)
|
|
}.catch { error in
|
|
Logger.error("failed to send ice update message to \(call.individualCall.thread.contactSessionID()) with error: \(error)")
|
|
callManager.signalingMessageDidFail(callId: callId)
|
|
}
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, shouldSendHangup callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, hangupType: HangupType, deviceId: UInt32, useLegacyHangupMessage: Bool) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("shouldSendHangup")
|
|
|
|
firstly { () throws -> Promise<Void> in
|
|
let type: IndividualCallMessage.HangupType
|
|
switch hangupType {
|
|
case .normal: type = .normal
|
|
case .accepted: type = .accepted
|
|
case .declined: type = .declined
|
|
case .busy: type = .busy
|
|
case .needPermission: type = .needPermission
|
|
}
|
|
let message = IndividualCallMessage()
|
|
message.callID = callId
|
|
message.kind = .hangup(type: type)
|
|
Storage.write { transaction in
|
|
MessageSender.send(message, in: call.individualCall.thread, using: transaction)
|
|
}
|
|
}.done {
|
|
Logger.debug("sent hangup message to \(call.individualCall.thread.contactSessionID()) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
|
|
try self.callManager.signalingMessageDidSend(callId: callId)
|
|
}.catch { error in
|
|
Logger.error("failed to send hangup message to \(call.individualCall.thread.contactSessionID()) with error: \(error)")
|
|
self.callManager.signalingMessageDidFail(callId: callId)
|
|
}
|
|
}
|
|
|
|
public func callManager(_ callManager: CallService.CallManagerType, shouldSendBusy callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(call.isIndividualCall)
|
|
Logger.info("shouldSendBusy")
|
|
|
|
firstly { () throws -> Promise<Void> in
|
|
let message = IndividualCallMessage()
|
|
message.callID = callId
|
|
message.kind = .busy
|
|
Storage.write { transaction in
|
|
MessageSender.send(message, in: call.individualCall.thread, using: transaction)
|
|
}
|
|
}.done {
|
|
Logger.debug("sent busy message to \(call.individualCall.thread.contactSessionID()) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
|
|
try self.callManager.signalingMessageDidSend(callId: callId)
|
|
}.catch { error in
|
|
Logger.error("failed to send busy message to \(call.individualCall.thread.contactSessionID()) with error: \(error)")
|
|
self.callManager.signalingMessageDidFail(callId: callId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Support Functions
|
|
|
|
/**
|
|
* User didn't answer incoming call
|
|
*/
|
|
public func handleMissedCall(_ call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("call: \(call)")
|
|
|
|
let callRecord: TSCall
|
|
if let existingCallRecord = call.individualCall.callRecord {
|
|
callRecord = existingCallRecord
|
|
} else {
|
|
callRecord = TSCall(
|
|
callType: .incomingMissed,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
call.individualCall.callRecord = callRecord
|
|
}
|
|
|
|
switch callRecord.callType {
|
|
case .incomingMissed:
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
callUIAdapter.reportMissedCall(call)
|
|
case .incomingIncomplete, .incoming:
|
|
callRecord.updateCallType(.incomingMissed)
|
|
callUIAdapter.reportMissedCall(call)
|
|
case .outgoingIncomplete:
|
|
callRecord.updateCallType(.outgoingMissed)
|
|
case .incomingMissedBecauseOfChangedIdentity, .incomingDeclined, .outgoingMissed, .outgoing, .incomingAnsweredElsewhere, .incomingDeclinedElsewhere, .incomingBusyElsewhere:
|
|
owsFailDebug("unexpected RPRecentCallType: \(callRecord.callType)")
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
@unknown default:
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
owsFailDebug("unknown RPRecentCallType: \(callRecord.callType)")
|
|
}
|
|
}
|
|
|
|
func handleAnsweredElsewhere(call: SignalCall) {
|
|
if let existingCallRecord = call.individualCall.callRecord {
|
|
// There should only be an existing call record due to a race where the call is answered
|
|
// simultaneously on multiple devices, and the caller is proceeding with the *other*
|
|
// devices call.
|
|
existingCallRecord.updateCallType(.incomingAnsweredElsewhere)
|
|
} else {
|
|
let callRecord = TSCall(
|
|
callType: .incomingAnsweredElsewhere,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
call.individualCall.callRecord = callRecord
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
}
|
|
|
|
call.individualCall.state = .answeredElsewhere
|
|
|
|
// Notify UI
|
|
callUIAdapter.didAnswerElsewhere(call: call)
|
|
|
|
callService.terminate(call: call)
|
|
}
|
|
|
|
func handleDeclinedElsewhere(call: SignalCall) {
|
|
if let existingCallRecord = call.individualCall.callRecord {
|
|
// There should only be an existing call record due to a race where the call is answered
|
|
// simultaneously on multiple devices, and the caller is proceeding with the *other*
|
|
// devices call.
|
|
existingCallRecord.updateCallType(.incomingDeclinedElsewhere)
|
|
} else {
|
|
let callRecord = TSCall(
|
|
callType: .incomingDeclinedElsewhere,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
call.individualCall.callRecord = callRecord
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
}
|
|
|
|
call.individualCall.state = .declinedElsewhere
|
|
|
|
// Notify UI
|
|
callUIAdapter.didDeclineElsewhere(call: call)
|
|
|
|
callService.terminate(call: call)
|
|
}
|
|
|
|
func handleBusyElsewhere(call: SignalCall) {
|
|
if let existingCallRecord = call.individualCall.callRecord {
|
|
// There should only be an existing call record due to a race where the call is answered
|
|
// simultaneously on multiple devices, and the caller is proceeding with the *other*
|
|
// devices call.
|
|
existingCallRecord.updateCallType(.incomingBusyElsewhere)
|
|
} else {
|
|
let callRecord = TSCall(
|
|
callType: .incomingBusyElsewhere,
|
|
offerType: call.individualCall.offerMediaType,
|
|
thread: call.individualCall.thread,
|
|
sentAtTimestamp: call.individualCall.sentAtTimestamp
|
|
)
|
|
call.individualCall.callRecord = callRecord
|
|
Storage.write { transaction in
|
|
callRecord.save(with: transaction)
|
|
}
|
|
}
|
|
|
|
call.individualCall.state = .busyElsewhere
|
|
|
|
// Notify UI
|
|
callUIAdapter.reportMissedCall(call)
|
|
|
|
callService.terminate(call: call)
|
|
}
|
|
|
|
/**
|
|
* The clients can now communicate via WebRTC, so we can let the UI know.
|
|
*
|
|
* Called by both caller and callee. Compatible ICE messages have been exchanged between the local and remote
|
|
* client.
|
|
*/
|
|
private func handleRinging(call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("call: \(call)")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .dialing:
|
|
if call.individualCall.state != .remoteRinging {
|
|
BenchEventComplete(eventId: "call-\(call.individualCall.localId)")
|
|
}
|
|
call.individualCall.state = .remoteRinging
|
|
case .answering:
|
|
if call.individualCall.state != .localRinging {
|
|
BenchEventComplete(eventId: "call-\(call.individualCall.localId)")
|
|
}
|
|
call.individualCall.state = .localRinging
|
|
self.callUIAdapter.reportIncomingCall(call, thread: call.individualCall.thread)
|
|
case .remoteRinging:
|
|
Logger.info("call already ringing. Ignoring \(#function): \(call).")
|
|
case .idle, .localRinging, .connected, .reconnecting, .localFailure, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteBusy, .answeredElsewhere, .declinedElsewhere, .busyElsewhere:
|
|
owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
|
|
}
|
|
}
|
|
|
|
private func handleReconnecting(call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("call: \(call)")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .remoteRinging, .localRinging:
|
|
Logger.debug("disconnect while ringing... we'll keep ringing")
|
|
case .connected:
|
|
call.individualCall.state = .reconnecting
|
|
default:
|
|
owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
|
|
}
|
|
}
|
|
|
|
private func handleReconnected(call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("call: \(call)")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
switch call.individualCall.state {
|
|
case .reconnecting:
|
|
call.individualCall.state = .connected
|
|
default:
|
|
owsFailDebug("unexpected call state: \(call.individualCall.state): \(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.
|
|
*/
|
|
private func handleConnected(call: SignalCall) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("call: \(call)")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
// End the background task.
|
|
call.individualCall.backgroundTask = nil
|
|
|
|
call.individualCall.state = .connected
|
|
|
|
// We don't risk transmitting any media until the remote client has admitted to being connected.
|
|
ensureAudioState(call: call)
|
|
callService.callManager.setLocalVideoEnabled(enabled: callService.shouldHaveLocalVideoTrack, call: call)
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
Logger.info("call: \(call)")
|
|
|
|
guard call === callService.currentCall else {
|
|
callService.cleanupStaleCall(call)
|
|
return
|
|
}
|
|
|
|
call.individualCall.isOnHold = isOnHold
|
|
|
|
ensureAudioState(call: call)
|
|
}
|
|
|
|
@objc
|
|
func handleCallKitStartVideo() {
|
|
AssertIsOnMainThread()
|
|
|
|
callService.updateIsLocalVideoMuted(isLocalVideoMuted: false)
|
|
}
|
|
|
|
/**
|
|
* 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]> {
|
|
|
|
return firstly {
|
|
AppEnvironment.shared.accountManager.getTurnServerInfo()
|
|
}.map(on: .global()) { turnServerInfo -> [RTCIceServer] in
|
|
Logger.debug("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])
|
|
}
|
|
} + [IndividualCallService.fallbackIceServer]
|
|
}.recover(on: .global()) { (error: Error) -> Guarantee<[RTCIceServer]> in
|
|
Logger.error("fetching ICE servers failed with error: \(error)")
|
|
Logger.warn("using fallback ICE Servers")
|
|
|
|
return Guarantee.value([IndividualCallService.fallbackIceServer])
|
|
}
|
|
}
|
|
|
|
public func handleCallKitProviderReset() {
|
|
AssertIsOnMainThread()
|
|
Logger.debug("")
|
|
|
|
// Return to a known good state by ending the current call, if any.
|
|
if let call = callService.currentCall {
|
|
handleFailedCall(failedCall: call, error: SignalCall.CallError.providerReset)
|
|
}
|
|
callManager.reset()
|
|
}
|
|
|
|
// 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: Error) {
|
|
AssertIsOnMainThread()
|
|
Logger.debug("")
|
|
|
|
let callError: SignalCall.CallError = {
|
|
switch error {
|
|
case let callError as SignalCall.CallError:
|
|
return callError
|
|
default:
|
|
return SignalCall.CallError.externalError(underlyingError: error)
|
|
}
|
|
}()
|
|
|
|
switch failedCall.individualCall.state {
|
|
case .answering, .localRinging:
|
|
assert(failedCall.individualCall.callRecord == nil)
|
|
// call failed before any call record could be created, make one now.
|
|
handleMissedCall(failedCall)
|
|
default:
|
|
assert(failedCall.individualCall.callRecord != nil)
|
|
}
|
|
|
|
guard !failedCall.individualCall.isEnded else {
|
|
Logger.debug("ignoring error: \(error) for already terminated call: \(failedCall)")
|
|
return
|
|
}
|
|
|
|
failedCall.error = callError
|
|
failedCall.individualCall.state = .localFailure
|
|
self.callUIAdapter.failCall(failedCall, error: callError)
|
|
|
|
Logger.error("call: \(failedCall) failed with error: \(error)")
|
|
callService.terminate(call: failedCall)
|
|
}
|
|
|
|
func ensureAudioState(call: SignalCall) {
|
|
owsAssertDebug(call.isIndividualCall)
|
|
let isLocalAudioMuted = call.individualCall.state != .connected || call.individualCall.isMuted || call.individualCall.isOnHold
|
|
callManager.setLocalAudioEnabled(enabled: !isLocalAudioMuted)
|
|
}
|
|
|
|
// MARK: CallViewController Timer
|
|
|
|
var activeCallTimer: Timer?
|
|
func startCallTimer() {
|
|
AssertIsOnMainThread()
|
|
|
|
stopAnyCallTimer()
|
|
assert(self.activeCallTimer == nil)
|
|
|
|
guard let call = callService.currentCall else {
|
|
owsFailDebug("Missing call.")
|
|
return
|
|
}
|
|
|
|
var hasUsedUpTimerSlop: Bool = false
|
|
|
|
self.activeCallTimer = WeakTimer.scheduledTimer(timeInterval: 1, target: self, userInfo: nil, repeats: true) { timer in
|
|
guard call === self.callService.currentCall else {
|
|
owsFailDebug("call has since ended. Timer should have been invalidated.")
|
|
timer.invalidate()
|
|
return
|
|
}
|
|
self.ensureCallScreenPresented(call: call, hasUsedUpTimerSlop: &hasUsedUpTimerSlop)
|
|
}
|
|
}
|
|
|
|
func ensureCallScreenPresented(call: SignalCall, hasUsedUpTimerSlop: inout Bool) {
|
|
guard callService.currentCall === call else {
|
|
owsFailDebug("obsolete call: \(call)")
|
|
return
|
|
}
|
|
|
|
guard let connectedDate = call.connectedDate else {
|
|
// Ignore; call hasn't connected yet.
|
|
return
|
|
}
|
|
|
|
let kMaxViewPresentationDelay: Double = 5
|
|
guard fabs(connectedDate.timeIntervalSinceNow) > kMaxViewPresentationDelay else {
|
|
// Ignore; call connected recently.
|
|
return
|
|
}
|
|
|
|
guard !OWSWindowManager.shared().hasCall() else {
|
|
// call screen is visible
|
|
return
|
|
}
|
|
|
|
guard hasUsedUpTimerSlop else {
|
|
// We hide the call screen synchronously, as soon as the user hangs up the call
|
|
// But it takes a while to communicate the hangup from the UI -> CallKit -> CallService
|
|
// However it's possible the timer fired the *instant* after the user hit the hangup
|
|
// button, so we allow one tick of the timer cycle as slop.
|
|
Logger.verbose("using up timer slop")
|
|
hasUsedUpTimerSlop = true
|
|
return
|
|
}
|
|
|
|
owsFailDebug("Call terminated due to missing call view.")
|
|
self.handleFailedCall(failedCall: call, error: OWSAssertionError("Call view didn't present after \(kMaxViewPresentationDelay) seconds"))
|
|
}
|
|
|
|
func stopAnyCallTimer() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.activeCallTimer?.invalidate()
|
|
self.activeCallTimer = nil
|
|
}
|
|
}
|
|
|
|
extension RPRecentCallType: CustomStringConvertible {
|
|
public var description: String {
|
|
switch self {
|
|
case .incoming:
|
|
return ".incoming"
|
|
case .outgoing:
|
|
return ".outgoing"
|
|
case .incomingMissed:
|
|
return ".incomingMissed"
|
|
case .outgoingIncomplete:
|
|
return ".outgoingIncomplete"
|
|
case .incomingIncomplete:
|
|
return ".incomingIncomplete"
|
|
case .incomingMissedBecauseOfChangedIdentity:
|
|
return ".incomingMissedBecauseOfChangedIdentity"
|
|
case .incomingDeclined:
|
|
return ".incomingDeclined"
|
|
case .outgoingMissed:
|
|
return ".outgoingMissed"
|
|
default:
|
|
owsFailDebug("unexpected RPRecentCallType: \(self.rawValue)")
|
|
return "RPRecentCallTypeUnknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension NSNumber {
|
|
convenience init?(value: UInt32?) {
|
|
guard let value = value else { return nil }
|
|
self.init(value: value)
|
|
}
|
|
}
|
|
|
|
extension TSRecentCallOfferType {
|
|
var asCallMediaType: CallMediaType {
|
|
switch self {
|
|
case .audio: return .audioCall
|
|
case .video: return .videoCall
|
|
}
|
|
}
|
|
}
|