session-ios/Session/Calls/Individual/IndividualCallService.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
}
}
}