TM-SGNL-iOS/Signal/Calls/IndividualCallService.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1343 lines
54 KiB
Swift

//
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CallKit
import Foundation
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI
import WebRTC
// MARK: - CallService
// This class' state should only be accessed on the main queue.
final class IndividualCallService: CallServiceStateObserver {
// MARK: Class
private let callManager: CallService.CallManagerType
private let callServiceState: CallServiceState
@MainActor
init(
callManager: CallService.CallManagerType,
callServiceState: CallServiceState
) {
self.callManager = callManager
self.callServiceState = callServiceState
SwiftSingletons.register(self)
self.callServiceState.addObserver(self)
}
private var audioSession: AudioSession { SUIEnvironment.shared.audioSessionRef }
private var callService: CallService { AppEnvironment.shared.callService }
private var callUIAdapter: CallUIAdapter { AppEnvironment.shared.callService.callUIAdapter }
private var contactManager: any ContactManager { SSKEnvironment.shared.contactManagerRef }
private var databaseStorage: SDSDatabaseStorage { SSKEnvironment.shared.databaseStorageRef }
private var networkManager: NetworkManager { SSKEnvironment.shared.networkManagerRef }
private var notificationPresenter: NotificationPresenter { SSKEnvironment.shared.notificationPresenterRef }
private var preferences: Preferences { SSKEnvironment.shared.preferencesRef }
private var profileManager: any ProfileManager { SSKEnvironment.shared.profileManagerRef }
private var tsAccountManager: any TSAccountManager { DependenciesBridge.shared.tsAccountManager }
private var identityManager: any OWSIdentityManager { DependenciesBridge.shared.identityManager }
@MainActor
func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
stopAnyCallTimer()
if let newValue {
switch newValue.mode {
case .individual:
startCallTimer(for: newValue)
case .groupThread, .callLink:
break
}
}
}
// MARK: - Call Control Actions
/**
* Initiate an outgoing call.
*/
@MainActor
func handleOutgoingCall(_ call: SignalCall) {
Logger.info("call: \(call)")
guard callServiceState.currentCall == nil else {
owsFailDebug("call already exists: \(String(describing: callServiceState.currentCall))")
return
}
// Create a call interaction for outgoing calls immediately.
call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingIncomplete)
// Get the current local device Id, must be valid for lifetime of the call.
let localDeviceId = tsAccountManager.storedDeviceIdWithMaybeTransaction
do {
try callManager.placeCall(call: call, callMediaType: call.individualCall.offerMediaType.asCallMediaType, localDevice: localDeviceId)
} catch {
self.handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: true)
}
}
/**
* User chose to answer the call. Used by the Callee only.
*/
@MainActor
public func handleAcceptCall(_ call: SignalCall) {
Logger.info("\(call)")
defer {
// This should only be non-nil if we had to defer accepting the call while waiting for RingRTC
// If it's set, we need to make sure we call it before returning.
call.individualCall.deferredAnswerCompletion?()
call.individualCall.deferredAnswerCompletion = nil
}
guard callServiceState.currentCall === call else {
let error = OWSAssertionError("accepting call: \(call) which is different from currentCall: \(callServiceState.currentCall as Optional)")
handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: true)
return
}
guard let callId = call.individualCall.callId else {
handleFailedCall(failedCall: call, error: OWSAssertionError("no callId for call: \(call)"), shouldResetUI: true, shouldResetRingRTC: true)
return
}
Logger.info("Creating call interaction: \(call)")
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingIncomplete)
// 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. It is also safer to do the
// audio session configuration before WebRTC starts operating on the audio resources
// via CallManager.accept().
handleConnected(call: call)
// Update the interaction now that we've accepted.
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incoming)
do {
try callManager.accept(callId: callId)
} catch {
self.handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: true)
}
}
/**
* Local user chose to end the call.
*/
@MainActor
func handleLocalHangupCall(_ call: SignalCall) {
Logger.info("\(call)")
guard call === callServiceState.currentCall else {
Logger.info("ignoring hangup for obsolete call: \(call)")
return
}
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
/**
* Received an incoming call Offer from call initiator.
*/
public func handleReceivedOffer(
caller: Aci,
sourceDevice: UInt32,
localIdentity: OWSIdentity,
callId: UInt64,
opaque: Data?,
sentAtTimestamp: UInt64,
serverReceivedTimestamp: UInt64,
serverDeliveryTimestamp: UInt64,
callType: SSKProtoCallMessageOfferType,
tx: SDSAnyWriteTransaction
) {
Logger.info("callId: \(callId), \(caller)")
guard let opaque else {
return
}
let callOfferHandler = CallOfferHandlerImpl(
identityManager: identityManager,
notificationPresenter: notificationPresenter,
profileManager: profileManager,
tsAccountManager: tsAccountManager
)
let partialResult = callOfferHandler.startHandlingOffer(
caller: caller,
sourceDevice: sourceDevice,
localIdentity: localIdentity,
callId: callId,
callType: callType,
sentAtTimestamp: sentAtTimestamp,
tx: tx
)
guard let partialResult else {
return
}
let individualCall = IndividualCall.incomingIndividualCall(
callId: callId,
thread: partialResult.thread,
sentAtTimestamp: sentAtTimestamp,
offerMediaType: partialResult.offerMediaType
)
// Get the current local device Id, must be valid for lifetime of the call.
let localDeviceId = tsAccountManager.storedDeviceId(tx: tx.asV2Read)
let isPrimaryDevice = tsAccountManager.registrationState(tx: tx.asV2Read).isPrimaryDevice ?? true
let newCall = SignalCall(individualCall: individualCall)
DispatchQueue.main.async {
let backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "\(#function)", completionBlock: { [weak newCall] status in
AssertIsOnMainThread()
guard status == .expired else {
return
}
// See if the newCall actually became the currentCall.
guard
case .individual(let currentCall) = self.callServiceState.currentCall?.mode,
let newCall,
newCall === currentCall
else {
Logger.warn("ignoring obsolete call")
return
}
let error = CallError.timeout(description: "background task time ran out before call connected")
self.handleFailedCall(failedCall: newCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
})
newCall.individualCall.backgroundTask = backgroundTask
var messageAgeSec: UInt64 = 0
if serverReceivedTimestamp > 0 && serverDeliveryTimestamp >= serverReceivedTimestamp {
messageAgeSec = (serverDeliveryTimestamp - serverReceivedTimestamp) / 1000
}
do {
try self.callManager.receivedOffer(
call: newCall,
sourceDevice: sourceDevice,
callId: callId,
opaque: opaque,
messageAgeSec: messageAgeSec,
callMediaType: newCall.individualCall.offerMediaType.asCallMediaType,
localDevice: localDeviceId,
isLocalDevicePrimary: isPrimaryDevice,
senderIdentityKey: partialResult.identityKeys.contactIdentityKey.publicKey.keyBytes.asData,
receiverIdentityKey: partialResult.identityKeys.localIdentityKey.publicKey.keyBytes.asData
)
} catch {
self.handleFailedCall(failedCall: newCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
}
}
}
/**
* Called by the call initiator after receiving an Answer from the callee.
*/
public func handleReceivedAnswer(
caller: Aci,
callId: UInt64,
sourceDevice: UInt32,
opaque: Data?,
tx: SDSAnyReadTransaction
) {
Logger.info("callId: \(callId), \(caller)")
guard let opaque else {
return
}
let identityKeys = identityManager.getCallIdentityKeys(remoteAci: caller, tx: tx)
DispatchQueue.main.async {
self._handleReceivedAnswer(callId: callId, sourceDevice: sourceDevice, opaque: opaque, identityKeys: identityKeys)
}
}
@MainActor
private func _handleReceivedAnswer(
callId: UInt64,
sourceDevice: UInt32,
opaque: Data,
identityKeys: CallIdentityKeys?
) {
guard let identityKeys else {
if let currentCall = callServiceState.currentCall, currentCall.individualCall?.callId == callId {
handleFailedCall(failedCall: currentCall, error: OWSAssertionError("missing identity keys"), shouldResetUI: true, shouldResetRingRTC: true)
}
return
}
do {
try callManager.receivedAnswer(
sourceDevice: sourceDevice,
callId: callId,
opaque: opaque,
senderIdentityKey: identityKeys.contactIdentityKey.publicKey.keyBytes.asData,
receiverIdentityKey: identityKeys.localIdentityKey.publicKey.keyBytes.asData
)
} catch {
owsFailDebug("error: \(error)")
if let currentCall = callServiceState.currentCall, currentCall.individualCall?.callId == callId {
handleFailedCall(failedCall: currentCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
}
}
}
/**
* Remote client (could be caller or callee) sent us a connectivity update.
*/
public func handleReceivedIceCandidates(caller: Aci, callId: UInt64, sourceDevice: UInt32, candidates: [SSKProtoCallMessageIceUpdate]) {
Logger.info("callId: \(callId), \(caller)")
let iceCandidates = candidates.filter { $0.id == callId && $0.opaque != nil }.map { $0.opaque! }
if iceCandidates.isEmpty {
return
}
DispatchQueue.main.async {
self._handleReceivedIceCandidates(callId: callId, sourceDevice: sourceDevice, iceCandidates: iceCandidates)
}
}
@MainActor
private func _handleReceivedIceCandidates(callId: UInt64, sourceDevice: UInt32, iceCandidates: [Data]) {
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(caller: Aci, callId: UInt64, sourceDevice: UInt32, type: SSKProtoCallMessageHangupType, deviceId: UInt32) {
Logger.info("callId: \(callId), \(caller)")
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
}
DispatchQueue.main.async {
self._handleReceivedHangup(callId: callId, sourceDevice: sourceDevice, hangupType: hangupType, deviceId: deviceId)
}
}
@MainActor
private func _handleReceivedHangup(callId: UInt64, sourceDevice: UInt32, hangupType: HangupType, deviceId: UInt32) {
do {
try callManager.receivedHangup(sourceDevice: sourceDevice, callId: callId, hangupType: hangupType, deviceId: deviceId)
} catch {
owsFailDebug("\(error)")
if let currentCall = callServiceState.currentCall, currentCall.individualCall?.callId == callId {
handleFailedCall(failedCall: currentCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
}
}
}
/**
* The callee was already in another call.
*/
public func handleReceivedBusy(caller: Aci, callId: UInt64, sourceDevice: UInt32) {
Logger.info("callId: \(callId), \(caller)")
DispatchQueue.main.async {
self._handleReceivedBusy(callId: callId, sourceDevice: sourceDevice)
}
}
@MainActor
private func _handleReceivedBusy(callId: UInt64, sourceDevice: UInt32) {
do {
try callManager.receivedBusy(sourceDevice: sourceDevice, callId: callId)
} catch {
owsFailDebug("\(error)")
if let currentCall = callServiceState.currentCall, currentCall.individualCall?.callId == callId {
handleFailedCall(failedCall: currentCall, error: error, shouldResetUI: true, shouldResetRingRTC: true)
}
}
}
// MARK: - Call Manager Events
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, shouldStartCall call: SignalCall, callId: UInt64, isOutgoing: Bool, callMediaType: CallMediaType, shouldEarlyRing: Bool) {
Logger.info("call: \(call)")
if shouldEarlyRing {
if !isOutgoing {
// If we are using the NSE, we need to kick off a ring ASAP in case this incoming call
// has resulted in the NSE waking up the main app.
Logger.info("Performing early ring")
handleRinging(call: call, isAnticipatory: true)
} else {
owsFailDebug("Cannot early ring an outgoing call")
}
}
// Start the call, asynchronously.
Task { @MainActor in
do {
let iceServers = try await RTCIceServerFetcher(networkManager: networkManager)
.getIceServers()
guard self.callServiceState.currentCall === call else {
Logger.debug("call has since ended")
return
}
let isSignalConnection = self.databaseStorage.read { tx in
return profileManager.isThread(inProfileWhitelist: call.individualCall.thread, transaction: tx)
}
if !isSignalConnection {
Logger.warn("Using relay server because remote user is not a Signal Connection")
}
let useTurnOnly = !isSignalConnection || self.preferences.doCallsHideIPAddress
let useLowData = self.callService.shouldUseLowDataWithSneakyTransaction(for: NetworkRoute(localAdapterType: .unknown))
Logger.info("Configuring call for \(useLowData ? "low" : "standard") data")
// Tell the Call Manager to proceed with its active call.
try self.callManager.proceed(callId: callId, iceServers: iceServers, hideIp: useTurnOnly, videoCaptureController: call.videoCaptureController, dataMode: useLowData ? .low : .normal, audioLevelsIntervalMillis: nil)
} catch {
owsFailDebug("\(error)")
guard call === self.callServiceState.currentCall else {
return
}
callManager.drop(callId: callId)
self.handleFailedCall(failedCall: call, error: error, shouldResetUI: true, shouldResetRingRTC: false)
}
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, onEvent call: SignalCall, event: CallManagerEvent) {
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:
defer {
callUIAdapter.recipientAcceptedCall(call.mode)
}
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
// Set the audio session configuration before audio is enabled in WebRTC
// via recipientAcceptedCall().
handleConnected(call: call)
// Update the call interaction now that we've connected.
call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoing)
case .endedLocalHangup:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
switch call.individualCall.callType {
case .some(.outgoingIncomplete):
call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
case .some:
break
case .none where [.localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting].contains(call.individualCall.state):
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingDeclined)
case .none:
owsFailDebug("missing call record")
}
// Make RTC audio inactive early in the hangup process before the state
// change resulting in any change to the default AudioSession.
audioSession.isRTCAudioEnabled = false
call.individualCall.state = .localHangup
ensureAudioState(call: call)
callServiceState.terminateCall(call)
case .endedRemoteHangup:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
// Make RTC audio inactive early in the hangup process before the state
// change resulting in any change to the default AudioSession.
audioSession.isRTCAudioEnabled = false
switch call.individualCall.state {
case .idle, .dialing, .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting, .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)
callServiceState.terminateCall(call)
case .endedRemoteHangupNeedPermission:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
audioSession.isRTCAudioEnabled = false
switch call.individualCall.state {
case .idle, .dialing, .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting, .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)
callServiceState.terminateCall(call)
case .endedRemoteHangupAccepted:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
audioSession.isRTCAudioEnabled = false
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)"), shouldResetUI: true, shouldResetRingRTC: true)
return
case .answering, .accepting, .connected:
Logger.info("tried answering locally, but answered somewhere else first. state: \(call.individualCall.state)")
handleAnsweredElsewhere(call: call)
case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting:
handleAnsweredElsewhere(call: call)
case .localFailure, .localHangup:
Logger.info("ignoring 'endedRemoteHangupAccepted' since call is already finished")
}
case .endedRemoteHangupDeclined:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
audioSession.isRTCAudioEnabled = false
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)"), shouldResetUI: true, shouldResetRingRTC: true)
return
case .answering, .accepting, .connected:
Logger.info("tried answering locally, but declined somewhere else first. state: \(call.individualCall.state)")
handleDeclinedElsewhere(call: call)
case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting:
handleDeclinedElsewhere(call: call)
case .localFailure, .localHangup:
Logger.info("ignoring 'endedRemoteHangupDeclined' since call is already finished")
}
case .endedRemoteHangupBusy:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
audioSession.isRTCAudioEnabled = false
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)"), shouldResetUI: true, shouldResetRingRTC: true)
return
case .answering, .accepting, .connected:
Logger.info("tried answering locally, but already in a call somewhere else first. state: \(call.individualCall.state)")
handleBusyElsewhere(call: call)
case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting:
handleBusyElsewhere(call: call)
case .localFailure, .localHangup:
Logger.info("ignoring 'endedRemoteHangupBusy' since call is already finished")
}
case .endedRemoteBusy:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
assert(call.individualCall.direction == .outgoing)
call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
call.individualCall.state = .remoteBusy
// Notify UI
callUIAdapter.remoteBusy(call)
callServiceState.terminateCall(call)
case .endedRemoteGlare, .endedRemoteReCall:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
if let callType = call.individualCall.callType {
switch callType {
case .outgoingMissed, .incomingDeclined, .incomingMissed, .incomingMissedBecauseOfChangedIdentity, .incomingAnsweredElsewhere, .incomingDeclinedElsewhere, .incomingBusyElsewhere, .incomingMissedBecauseOfDoNotDisturb, .incomingMissedBecauseBlockedSystemContact:
// already handled and ended, don't update the call record.
break
case .incomingIncomplete, .incoming:
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingMissed)
callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
case .outgoingIncomplete:
call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
callUIAdapter.remoteBusy(call)
case .outgoing:
call.individualCall.createOrUpdateCallInteractionAsync(callType: .outgoingMissed)
callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
@unknown default:
owsFailDebug("unknown RPRecentCallType: \(callType)")
}
} else {
assert(call.individualCall.direction == .incoming)
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingMissed)
callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
}
call.individualCall.state = .localHangup
callServiceState.terminateCall(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: CallError.timeout(description: description), shouldResetUI: true, shouldResetRingRTC: false)
case .endedSignalingFailure, .endedGlareHandlingFailure:
handleFailedCall(failedCall: call, error: CallError.signaling, shouldResetUI: true, shouldResetRingRTC: false)
case .endedInternalFailure:
handleFailedCall(failedCall: call, error: OWSAssertionError("call manager internal error"), shouldResetUI: true, shouldResetRingRTC: false)
case .endedConnectionFailure:
handleFailedCall(failedCall: call, error: CallError.disconnected, shouldResetUI: true, shouldResetRingRTC: false)
case .endedDropped:
Logger.debug("")
// An incoming call was dropped, ignoring because we have already
// failed the call on the screen.
case .remoteAudioEnable:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.isRemoteAudioMuted = false
case .remoteAudioDisable:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.isRemoteAudioMuted = true
case .remoteVideoEnable:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.isRemoteVideoEnabled = true
case .remoteVideoDisable:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.isRemoteVideoEnabled = false
case .remoteSharingScreenEnable:
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.isRemoteSharingScreen = true
case .remoteSharingScreenDisable:
guard call === callServiceState.currentCall else {
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
callServiceState.terminateCall(call)
case .receivedOfferWhileActive:
handleMissedCall(call)
// TODO - This should not be a failure.
call.individualCall.state = .localFailure
callServiceState.terminateCall(call)
case .receivedOfferWithGlare:
handleMissedCall(call)
// TODO - This should not be a failure.
call.individualCall.state = .localFailure
callServiceState.terminateCall(call)
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, onUpdateLocalVideoSession call: SignalCall, session: AVCaptureSession?) {
Logger.info("onUpdateLocalVideoSession")
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, onAddRemoteVideoTrack call: SignalCall, track: RTCVideoTrack) {
Logger.info("onAddRemoteVideoTrack")
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.remoteVideoTrack = track
}
// MARK: - Call Manager Signaling
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, shouldSendOffer callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, opaque: Data, callMediaType: CallMediaType) {
Logger.info("shouldSendOffer")
Task {
do {
let offerBuilder = SSKProtoCallMessageOffer.builder(id: callId)
offerBuilder.setOpaque(opaque)
switch callMediaType {
case .audioCall: offerBuilder.setType(.offerAudioCall)
case .videoCall: offerBuilder.setType(.offerVideoCall)
}
let sendPromise = try await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
let callMessage = OWSOutgoingCallMessage(
thread: call.individualCall.thread,
offerMessage: try offerBuilder.build(),
destinationDeviceId: NSNumber(value: destinationDeviceId),
transaction: tx
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: callMessage
)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
limitToCurrentProcessLifetime: true,
isHighPriority: true,
transaction: tx
)
}
try await sendPromise.awaitable()
Logger.info("sent offer message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
try self.callManager.signalingMessageDidSend(callId: callId)
} catch {
Logger.error("failed to send offer message to \(call.individualCall.thread.contactAddress) with error: \(error)")
self.callManager.signalingMessageDidFail(callId: callId)
}
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, shouldSendAnswer callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, opaque: Data) {
Logger.info("shouldSendAnswer")
Task {
do {
let answerBuilder = SSKProtoCallMessageAnswer.builder(id: callId)
answerBuilder.setOpaque(opaque)
let sendPromise = try await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
let callMessage = OWSOutgoingCallMessage(
thread: call.individualCall.thread,
answerMessage: try answerBuilder.build(),
destinationDeviceId: NSNumber(value: destinationDeviceId),
transaction: tx
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: callMessage
)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
limitToCurrentProcessLifetime: true,
isHighPriority: true,
transaction: tx
)
}
try await sendPromise.awaitable()
Logger.debug("sent answer message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
try self.callManager.signalingMessageDidSend(callId: callId)
} catch {
Logger.error("failed to send answer message to \(call.individualCall.thread.contactAddress) with error: \(error)")
self.callManager.signalingMessageDidFail(callId: callId)
}
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, shouldSendIceCandidates callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, candidates: [Data]) {
Logger.info("shouldSendIceCandidates")
Task {
do {
var iceUpdateProtos = [SSKProtoCallMessageIceUpdate]()
for iceCandidate in candidates {
let iceUpdateProto: SSKProtoCallMessageIceUpdate
let iceUpdateBuilder = SSKProtoCallMessageIceUpdate.builder(id: callId)
iceUpdateBuilder.setOpaque(iceCandidate)
iceUpdateProto = try iceUpdateBuilder.build()
iceUpdateProtos.append(iceUpdateProto)
}
guard !iceUpdateProtos.isEmpty else {
throw OWSAssertionError("no ice updates to send")
}
let sendPromise = await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
let callMessage = OWSOutgoingCallMessage(
thread: call.individualCall.thread,
iceUpdateMessages: iceUpdateProtos,
destinationDeviceId: NSNumber(value: destinationDeviceId),
transaction: tx
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: callMessage
)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
limitToCurrentProcessLifetime: true,
isHighPriority: true,
transaction: tx
)
}
try await sendPromise.awaitable()
Logger.debug("sent ice update message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
try self.callManager.signalingMessageDidSend(callId: callId)
} catch {
Logger.error("failed to send ice update message to \(call.individualCall.thread.contactAddress) with error: \(error)")
callManager.signalingMessageDidFail(callId: callId)
}
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, shouldSendHangup callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?, hangupType: HangupType, deviceId: UInt32) {
Logger.info("shouldSendHangup")
Task {
do {
let sendPromise = await self.databaseStorage.awaitableWrite { tx in
return CallHangupSender.sendHangup(
thread: call.individualCall.thread,
callId: callId,
hangupType: { () -> SSKProtoCallMessageHangupType in
switch hangupType {
case .normal: return .hangupNormal
case .accepted: return .hangupAccepted
case .declined: return .hangupDeclined
case .busy: return .hangupBusy
case .needPermission: return .hangupNeedPermission
}
}(),
localDeviceId: deviceId,
remoteDeviceId: destinationDeviceId,
tx: tx
)
}
try await sendPromise.awaitable()
Logger.debug("sent hangup message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
try self.callManager.signalingMessageDidSend(callId: callId)
} catch {
Logger.error("failed to send hangup message to \(call.individualCall.thread.contactAddress) with error: \(error)")
self.callManager.signalingMessageDidFail(callId: callId)
}
}
}
@MainActor
public func callManager(_ callManager: CallService.CallManagerType, shouldSendBusy callId: UInt64, call: SignalCall, destinationDeviceId: UInt32?) {
Logger.info("shouldSendBusy")
Task {
do {
let busyBuilder = SSKProtoCallMessageBusy.builder(id: callId)
let sendPromise = try await self.databaseStorage.awaitableWrite { tx -> Promise<Void> in
let callMessage = OWSOutgoingCallMessage(
thread: call.individualCall.thread,
busyMessage: try busyBuilder.build(),
destinationDeviceId: NSNumber(value: destinationDeviceId),
transaction: tx
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: callMessage
)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
limitToCurrentProcessLifetime: true,
isHighPriority: true,
transaction: tx
)
}
try await sendPromise.awaitable()
Logger.debug("sent busy message to \(call.individualCall.thread.contactAddress) device: \((destinationDeviceId != nil) ? String(destinationDeviceId!) : "nil")")
try self.callManager.signalingMessageDidSend(callId: callId)
} catch {
Logger.error("failed to send busy message to \(call.individualCall.thread.contactAddress) with error: \(error)")
self.callManager.signalingMessageDidFail(callId: callId)
}
}
}
// MARK: - Support Functions
/**
* User didn't answer incoming call
*/
@MainActor
public func handleMissedCall(_ call: SignalCall, error: CallError? = nil) {
Logger.info("call: \(call)")
let callType: RPRecentCallType
switch error {
case .doNotDisturbEnabled?:
callType = .incomingMissedBecauseOfDoNotDisturb
case .contactIsBlocked:
callType = .incomingMissedBecauseBlockedSystemContact
default:
if call.individualCall?.direction == .outgoing {
callType = .outgoingMissed
} else {
callType = .incomingMissed
}
}
let oldCallType = call.individualCall.callType
call.individualCall.createOrUpdateCallInteractionAsync(callType: callType)
switch oldCallType {
case .incomingMissed, .none:
callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
case .incomingIncomplete, .incoming:
callUIAdapter.reportMissedCall(call, individualCall: call.individualCall)
case .outgoingIncomplete, .incomingDeclined, .incomingDeclinedElsewhere, .incomingAnsweredElsewhere:
break
case .incomingMissedBecauseOfChangedIdentity, .outgoingMissed, .outgoing, .incomingBusyElsewhere, .incomingMissedBecauseOfDoNotDisturb, .incomingMissedBecauseBlockedSystemContact:
owsFailDebug("unexpected RPRecentCallType: \(String(describing: oldCallType))")
@unknown default:
owsFailDebug("unknown RPRecentCallType: \(String(describing: oldCallType))")
}
}
@MainActor
func handleAnsweredElsewhere(call: SignalCall) {
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingAnsweredElsewhere)
call.individualCall.state = .answeredElsewhere
// Notify UI
callUIAdapter.didAnswerElsewhere(call: call)
callServiceState.terminateCall(call)
}
@MainActor
func handleDeclinedElsewhere(call: SignalCall) {
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingDeclinedElsewhere)
call.individualCall.state = .declinedElsewhere
// Notify UI
callUIAdapter.didDeclineElsewhere(call: call)
callServiceState.terminateCall(call)
}
@MainActor
func handleBusyElsewhere(call: SignalCall) {
call.individualCall.createOrUpdateCallInteractionAsync(callType: .incomingBusyElsewhere)
call.individualCall.state = .busyElsewhere
// Notify UI
callUIAdapter.wasBusyElsewhere(call: call)
callServiceState.terminateCall(call)
}
/**
* Present UI to begin ringing.
*
* This can be performed in response to:
* - Established communication via WebRTC
* - Anticipation of an expected future ring.
*
* In the former case, compatible ICE messages have been exchanged between the local and remote
* client and we can ring with confidence that the call will connect.
*
* In the latter case, the ring is performed before any messages have been exchanged. This is to satisfy
* callservicesd which requires that we post a CallKit ring shortly after the NSE wakes the main app.
*/
@MainActor
private func handleRinging(call: SignalCall, isAnticipatory: Bool = false) {
// Only incoming calls can use the early ring states
owsAssertDebug(!(call.individualCall.direction == .outgoing && isAnticipatory))
Logger.info("call: \(call)")
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
switch call.individualCall.state {
case .dialing:
call.individualCall.state = .remoteRinging
case .answering:
call.individualCall.state = isAnticipatory ? .localRinging_Anticipatory : .localRinging_ReadyToAnswer
callUIAdapter.reportIncomingCall(call)
case .localRinging_Anticipatory:
// RingRTC became ready during our anticipatory ring. User hasn't tried to answer yet.
owsAssertDebug(isAnticipatory == false)
call.individualCall.state = .localRinging_ReadyToAnswer
case .accepting:
// The user answered during our early ring, but we've been waiting for RingRTC to tell us to start
// actually ringing before trying to accept. We can do that now.
handleAcceptCall(call)
case .remoteRinging:
Logger.info("call already ringing. Ignoring \(#function): \(call).")
case .idle, .connected, .reconnecting, .localFailure, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteBusy, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .localRinging_ReadyToAnswer:
owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
}
}
@MainActor
private func handleReconnecting(call: SignalCall) {
Logger.info("call: \(call)")
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
switch call.individualCall.state {
case .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting:
Logger.debug("disconnect while ringing... we'll keep ringing")
case .connected:
call.individualCall.state = .reconnecting
default:
owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).")
}
}
@MainActor
private func handleReconnected(call: SignalCall) {
Logger.info("call: \(call)")
guard call === callServiceState.currentCall else {
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.
*/
@MainActor
private func handleConnected(call: SignalCall) {
owsPrecondition(call === callServiceState.currentCall)
Logger.info("call: \(call)")
// 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.updateIsVideoEnabled()
}
/**
* Local user toggled to hold call. Currently only possible via CallKit screen,
* e.g. when another Call comes in.
*/
@MainActor
func setIsOnHold(call: SignalCall, isOnHold: Bool) {
Logger.info("call: \(call)")
guard call === callServiceState.currentCall else {
cleanUpStaleCall(call)
return
}
call.individualCall.isOnHold = isOnHold
ensureAudioState(call: call)
}
@MainActor
public func handleCallKitProviderReset() {
Logger.debug("")
// Return to a known good state by ending the current call, if any.
if let call = callServiceState.currentCall {
handleFailedCall(failedCall: call, error: CallError.providerReset, shouldResetUI: false, shouldResetRingRTC: true)
}
}
@MainActor
func cleanUpStaleCall(_ staleCall: SignalCall, function: StaticString = #function, line: UInt = #line) {
assert(staleCall !== callServiceState.currentCall)
if let currentCall = callServiceState.currentCall {
let error = OWSAssertionError("trying \(function):\(line) for call: \(staleCall) which is not currentCall: \(currentCall as Optional)")
handleFailedCall(failedCall: staleCall, error: error, shouldResetUI: false, shouldResetRingRTC: true)
} else {
Logger.info("ignoring \(function):\(line) for call: \(staleCall) since currentCall has ended.")
}
}
// This method should be called when an error occurred for a call from
// the UI/UX or the RingRTC library.
//
// * 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.
@MainActor
public func handleFailedCall(failedCall: SignalCall, error: Error, shouldResetUI: Bool, shouldResetRingRTC: Bool) {
Logger.debug("")
let callError = CallError.wrapErrorIfNeeded(error)
switch failedCall.individualCall.state {
case .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting:
assert(failedCall.individualCall.callType == nil)
// call failed before any call record could be created, make one now.
handleMissedCall(failedCall, error: callError)
default:
assert(failedCall.individualCall.callType != nil)
}
guard !failedCall.individualCall.isEnded else {
Logger.debug("ignoring error: \(error) for already terminated call: \(failedCall)")
return
}
failedCall.individualCall.error = callError
failedCall.individualCall.state = .localFailure
if shouldResetUI {
callUIAdapter.failCall(failedCall, error: callError)
}
if callError.shouldSilentlyDropCall(), let callId = failedCall.individualCall.callId {
// Drop the call explicitly to avoid sending a hangup.
callManager.drop(callId: callId)
} else if shouldResetRingRTC {
callManager.reset()
}
Logger.error("call: \(failedCall) failed with error: \(error)")
callServiceState.terminateCall(failedCall)
}
@MainActor
func ensureAudioState(call: SignalCall) {
let isLocalAudioMuted = call.individualCall.state != .connected || call.individualCall.isMuted || call.individualCall.isOnHold
callManager.setLocalAudioEnabled(enabled: !isLocalAudioMuted)
}
// MARK: CallViewController Timer
private var activeCallTimer: Timer?
@MainActor
func startCallTimer(for call: SignalCall) {
var hasUsedUpTimerSlop: Bool = false
assert(self.activeCallTimer == nil)
self.activeCallTimer = WeakTimer.scheduledTimer(timeInterval: 1, target: self, userInfo: nil, repeats: true) { timer in
guard call === self.callServiceState.currentCall else {
owsFailDebug("call has since ended. Timer should have been invalidated.")
timer.invalidate()
return
}
self.ensureCallScreenPresented(call: call, hasUsedUpTimerSlop: &hasUsedUpTimerSlop)
}
}
@MainActor
private func ensureCallScreenPresented(call: SignalCall, hasUsedUpTimerSlop: inout Bool) {
guard let connectedDate = call.commonState.connectedDate else {
// Ignore; call hasn't connected yet.
return
}
let kMaxViewPresentationDelay: UInt64 = 5
guard MonotonicDate() - connectedDate > kMaxViewPresentationDelay*NSEC_PER_SEC else {
// Ignore; call connected recently.
return
}
guard !AppEnvironment.shared.windowManagerRef.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"),
shouldResetUI: true,
shouldResetRingRTC: true
)
}
private func stopAnyCallTimer() {
AssertIsOnMainThread()
self.activeCallTimer?.invalidate()
self.activeCallTimer = nil
}
enum InteractionUpdateMethod {
case writeAsync
case inTransaction(SDSAnyWriteTransaction)
}
}
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
}
}
}
private extension SignalCall {
var individualCall: IndividualCall! {
switch self.mode {
case .individual(let individualCall):
return individualCall
case .groupThread, .callLink:
owsFail("Must have individual call.")
}
}
}