Signal-iOS/Signal/Calls/CallService.swift
2024-06-17 12:19:00 -05:00

1588 lines
59 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVFoundation
import LibSignalClient
import SignalServiceKit
import SignalRingRTC
import SignalUI
import WebRTC
/// Manages events related to both 1:1 and group calls, while the main app is
/// running.
///
/// Responsible for the 1:1 or group call this device is currently active in, if
/// any, as well as any other updates to other calls that we learn about.
final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
public typealias CallManagerType = CallManager<SignalCall, CallService>
public let callManager: CallManagerType
// Even though we never use this, we need to retain it to ensure
// `callManager` continues to work properly.
private let callManagerHttpClient: AnyObject
private var audioSession: AudioSession { NSObject.audioSession }
private let authCredentialManager: any AuthCredentialManager
private var databaseStorage: SDSDatabaseStorage { NSObject.databaseStorage }
private var deviceSleepManager: DeviceSleepManager { DeviceSleepManager.shared }
private var groupCallManager: GroupCallManager { NSObject.groupCallManager }
private var reachabilityManager: SSKReachabilityManager { NSObject.reachabilityManager }
public var callUIAdapter: CallUIAdapter
let individualCallService: IndividualCallService
let groupCallRemoteVideoManager: GroupCallRemoteVideoManager
let callLinkManager: CallLinkManagerImpl
/// Needs to be lazily initialized, because it uses singletons that are not
/// available when this class is initialized.
private lazy var groupCallAccessoryMessageDelegate: GroupCallAccessoryMessageDelegate = {
return GroupCallAccessoryMessageHandler(
databaseStorage: databaseStorage,
groupCallRecordManager: DependenciesBridge.shared.groupCallRecordManager,
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef
)
}()
/// Needs to be lazily initialized, because it uses singletons that are not
/// available when this class is initialized.
private lazy var groupCallRecordRingUpdateDelegate: GroupCallRecordRingUpdateDelegate = {
return GroupCallRecordRingUpdateHandler(
callRecordStore: DependenciesBridge.shared.callRecordStore,
groupCallRecordManager: DependenciesBridge.shared.groupCallRecordManager,
interactionStore: DependenciesBridge.shared.interactionStore,
threadStore: DependenciesBridge.shared.threadStore
)
}()
private(set) lazy var audioService: CallAudioService = {
let result = CallAudioService(audioSession: self.audioSession)
callServiceState.addObserver(result, syncStateImmediately: true)
return result
}()
public let earlyRingNextIncomingCall = AtomicBool(false, lock: .init())
let callServiceState: CallServiceState
public init(
appContext: any AppContext,
authCredentialManager: any AuthCredentialManager,
callLinkPublicParams: GenericServerPublicParams,
mutableCurrentCall: AtomicValue<SignalCall?>,
networkManager: NetworkManager,
tsAccountManager: any TSAccountManager
) {
self.authCredentialManager = authCredentialManager
let httpClient = CallHTTPClient()
self.callManager = CallManager(
httpClient: httpClient.ringRtcHttpClient,
fieldTrials: RingrtcFieldTrials.trials(with: appContext.appUserDefaults())
)
self.callManagerHttpClient = httpClient
let callUIAdapter = CallUIAdapter()
self.callUIAdapter = callUIAdapter
self.callServiceState = CallServiceState(currentCall: mutableCurrentCall)
self.individualCallService = IndividualCallService(
callManager: self.callManager,
callServiceState: self.callServiceState
)
self.groupCallRemoteVideoManager = GroupCallRemoteVideoManager(
callServiceState: self.callServiceState
)
self.callLinkManager = CallLinkManagerImpl(
networkManager: networkManager,
serverParams: callLinkPublicParams,
tsAccountManager: tsAccountManager
)
self.callManager.delegate = self
SwiftSingletons.register(self)
self.callServiceState.addObserver(self)
NotificationCenter.default.addObserver(
self,
selector: #selector(didEnterBackground),
name: .OWSApplicationDidEnterBackground,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(configureDataMode),
name: Self.callServicePreferencesDidChange,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationChanged),
name: .registrationStateDidChange,
object: nil)
// Note that we're not using the usual .owsReachabilityChanged
// We want to update our data mode if the app has been backgrounded
NotificationCenter.default.addObserver(
self,
selector: #selector(configureDataMode),
name: .reachabilityChanged,
object: nil
)
// We don't support a rotating call screen on phones,
// but we do still want to rotate the various icons.
if !UIDevice.current.isIPad {
NotificationCenter.default.addObserver(
self,
selector: #selector(phoneOrientationDidChange),
name: UIDevice.orientationDidChangeNotification,
object: nil
)
}
AppReadiness.runNowOrWhenAppWillBecomeReady {
if let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci {
self.callManager.setSelfUuid(localAci.rawUUID)
}
}
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
SDSDatabaseStorage.shared.appendDatabaseChangeDelegate(self)
self.callServiceState.addObserver(self.groupCallAccessoryMessageDelegate, syncStateImmediately: true)
self.callServiceState.addObserver(self.groupCallRemoteVideoManager, syncStateImmediately: true)
}
}
/**
* Choose whether to use CallKit or a Notification backed interface for calling.
*/
public func rebuildCallUIAdapter() {
AssertIsOnMainThread()
if let currentCall = callServiceState.currentCall {
Logger.warn("ending current call in. Did user toggle callkit preference while in a call?")
callServiceState.terminateCall(currentCall)
}
self.callUIAdapter = CallUIAdapter()
}
func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
switch oldValue?.mode {
case nil:
break
case .individual(let call):
call.removeObserver(self)
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
call.removeObserver(self)
}
switch newValue?.mode {
case nil:
break
case .individual(let call):
call.addObserverAndSyncState(self)
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
call.addObserverAndSyncState(self)
}
updateIsVideoEnabled()
// Prevent device from sleeping while we have an active call.
if let oldValue {
self.deviceSleepManager.removeBlock(blockObject: oldValue)
}
if let newValue {
self.deviceSleepManager.addBlock(blockObject: newValue)
}
if !UIDevice.current.isIPad {
if oldValue != nil {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
}
if newValue != nil {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
}
}
switch newValue?.mode {
case .individual:
// By default, individual calls should start out with speakerphone disabled.
self.audioService.requestSpeakerphone(isEnabled: false)
case .groupThread, .callLink, nil:
break
}
// To be safe, we reset the early ring on any call change so it's not left set from an unexpected state change.
earlyRingNextIncomingCall.set(false)
}
func callServiceState(_ callServiceState: CallServiceState, didTerminateCall call: SignalCall) {
if callServiceState.currentCall == nil {
audioSession.isRTCAudioEnabled = false
}
audioSession.endAudioActivity(call.commonState.audioActivity)
switch call.mode {
case .individual:
break
case .groupThread(let call):
// Kick off a peek now that we've disconnected to get an updated participant state.
Task {
await self.groupCallManager.peekGroupCallAndUpdateThread(
call.groupThread,
peekTrigger: .localEvent()
)
}
case .callLink:
// [CallLink] TODO: .
break
}
}
// MARK: -
/**
* Local user toggled to mute audio.
*/
func updateIsLocalAudioMuted(isLocalAudioMuted: Bool) {
AssertIsOnMainThread()
// Keep a reference to the call before permissions were requested...
guard let currentCall = callServiceState.currentCall else {
owsFailDebug("missing currentCall")
return
}
// If we're disabling the microphone, we don't need permission. Only need
// permission to *enable* the microphone.
guard !isLocalAudioMuted else {
return updateIsLocalAudioMutedWithMicrophonePermission(call: currentCall, isLocalAudioMuted: isLocalAudioMuted)
}
// This method can be initiated either from the CallViewController.videoButton or via CallKit
// in either case we want to show the alert on the callViewWindow.
guard let frontmostViewController =
UIApplication.shared.findFrontmostViewController(ignoringAlerts: true,
window: WindowManager.shared.callViewWindow) else {
owsFailDebug("could not identify frontmostViewController")
return
}
frontmostViewController.ows_askForMicrophonePermissions { granted in
// Make sure the call is still valid (the one we asked permissions for).
guard self.callServiceState.currentCall === currentCall else {
Logger.info("ignoring microphone permissions for obsolete call")
return
}
if !granted {
frontmostViewController.ows_showNoMicrophonePermissionActionSheet()
}
let mutedAfterAskingForPermission = !granted
self.updateIsLocalAudioMutedWithMicrophonePermission(call: currentCall, isLocalAudioMuted: mutedAfterAskingForPermission)
}
}
private func updateIsLocalAudioMutedWithMicrophonePermission(call: SignalCall, isLocalAudioMuted: Bool) {
AssertIsOnMainThread()
owsAssert(call === callServiceState.currentCall)
switch call.mode {
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
call.ringRtcCall.isOutgoingAudioMuted = isLocalAudioMuted
call.groupCall(onLocalDeviceStateChanged: call.ringRtcCall)
case .individual(let individualCall):
individualCall.isMuted = isLocalAudioMuted
individualCallService.ensureAudioState(call: call)
}
}
/**
* Local user toggled video.
*/
func updateIsLocalVideoMuted(isLocalVideoMuted: Bool) {
AssertIsOnMainThread()
// Keep a reference to the call before permissions were requested...
guard let currentCall = callServiceState.currentCall else {
owsFailDebug("missing currentCall")
return
}
// If we're disabling local video, we don't need permission. Only need
// permission to *enable* video.
guard !isLocalVideoMuted else {
return updateIsLocalVideoMutedWithCameraPermissions(call: currentCall, isLocalVideoMuted: isLocalVideoMuted)
}
// This method can be initiated either from the CallViewController.videoButton or via CallKit
// in either case we want to show the alert on the callViewWindow.
let frontmostViewController = UIApplication.shared.findFrontmostViewController(
ignoringAlerts: true,
window: WindowManager.shared.callViewWindow
)
guard let frontmostViewController else {
owsFailDebug("could not identify frontmostViewController")
return
}
frontmostViewController.ows_askForCameraPermissions { granted in
// Make sure the call is still valid (the one we asked permissions for).
guard self.callServiceState.currentCall === currentCall else {
Logger.info("ignoring camera permissions for obsolete call")
return
}
let mutedAfterAskingForPermission = !granted
self.updateIsLocalVideoMutedWithCameraPermissions(call: currentCall, isLocalVideoMuted: mutedAfterAskingForPermission)
}
}
private func updateIsLocalVideoMutedWithCameraPermissions(call: SignalCall, isLocalVideoMuted: Bool) {
AssertIsOnMainThread()
owsAssert(call === callServiceState.currentCall)
switch call.mode {
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
call.ringRtcCall.isOutgoingVideoMuted = isLocalVideoMuted
call.groupCall(onLocalDeviceStateChanged: call.ringRtcCall)
case .individual(let individualCall):
individualCall.hasLocalVideo = !isLocalVideoMuted
}
updateIsVideoEnabled()
}
func updateCameraSource(call: SignalCall, isUsingFrontCamera: Bool) {
AssertIsOnMainThread()
call.videoCaptureController.switchCamera(isUsingFrontCamera: isUsingFrontCamera)
}
@objc
private func configureDataMode() {
guard AppReadiness.isAppReady else { return }
guard let currentCall = callServiceState.currentCall else { return }
switch currentCall.mode {
case .groupThread(let call):
let useLowData = shouldUseLowDataWithSneakyTransaction(for: call.ringRtcCall.localDeviceState.networkRoute)
Logger.info("Configuring call for \(useLowData ? "low" : "standard") data")
call.ringRtcCall.updateDataMode(dataMode: useLowData ? .low : .normal)
case let .individual(call) where call.state == .connected:
let useLowData = shouldUseLowDataWithSneakyTransaction(for: call.networkRoute)
Logger.info("Configuring call for \(useLowData ? "low" : "standard") data")
callManager.updateDataMode(dataMode: useLowData ? .low : .normal)
default:
// Do nothing. We'll reapply the data mode once connected
break
}
}
func shouldUseLowDataWithSneakyTransaction(for networkRoute: NetworkRoute) -> Bool {
let highDataInterfaces = databaseStorage.read { readTx in
Self.highDataNetworkInterfaces(readTx: readTx)
}
if let allowsHighData = highDataInterfaces.includes(networkRoute.localAdapterType) {
return !allowsHighData
}
// If we aren't sure whether the current route's high-data, fall back to checking reachability.
// This also handles the situation where WebRTC doesn't know what interface we're on,
// which is always true on iOS 11.
return !reachabilityManager.isReachable(with: highDataInterfaces)
}
// MARK: -
// 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()
switch failedCall.mode {
case .individual:
individualCallService.handleFailedCall(
failedCall: failedCall,
error: error,
shouldResetUI: false,
shouldResetRingRTC: true
)
case .groupThread(let groupCall as GroupCall), .callLink(let groupCall as GroupCall):
leaveAndTerminateGroupCall(failedCall, groupCall: groupCall)
}
}
func handleLocalHangupCall(_ call: SignalCall) {
AssertIsOnMainThread()
switch call.mode {
case .individual:
individualCallService.handleLocalHangupCall(call)
case .groupThread(let groupThreadCall):
if case .incomingRing(_, let ringId) = groupThreadCall.groupCallRingState {
let groupThread = groupThreadCall.groupThread
groupCallAccessoryMessageDelegate.localDeviceDeclinedGroupRing(
ringId: ringId,
groupThread: groupThread
)
do {
try callManager.cancelGroupRing(
groupId: groupThread.groupId,
ringId: ringId,
reason: .declinedByUser
)
} catch {
owsFailDebug("RingRTC failed to cancel group ring \(ringId): \(error)")
}
}
leaveAndTerminateGroupCall(call, groupCall: groupThreadCall)
case .callLink(let callLinkCall):
leaveAndTerminateGroupCall(call, groupCall: callLinkCall)
}
}
// MARK: - Video
var shouldHaveLocalVideoTrack: Bool {
AssertIsOnMainThread()
guard let call = self.callServiceState.currentCall else {
return false
}
// The iOS simulator doesn't provide any sort of camera capture
// support or emulation (http://goo.gl/rHAnC1) so don't bother
// trying to open a local stream.
guard !Platform.isSimulator else { return false }
guard UIApplication.shared.applicationState != .background else { return false }
switch call.mode {
case .individual(let individualCall):
return individualCall.state == .connected && individualCall.hasLocalVideo
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
return !call.ringRtcCall.isOutgoingVideoMuted
}
}
func updateIsVideoEnabled() {
AssertIsOnMainThread()
guard let call = self.callServiceState.currentCall else { return }
switch call.mode {
case .individual(let individualCall):
if individualCall.state == .connected || individualCall.state == .reconnecting {
callManager.setLocalVideoEnabled(enabled: shouldHaveLocalVideoTrack, call: call)
} else {
// If we're not yet connected, just enable the camera but don't tell RingRTC
// to start sending video. This allows us to show a "vanity" view while connecting.
if !Platform.isSimulator && individualCall.hasLocalVideo {
individualCall.videoCaptureController.startCapture()
} else {
individualCall.videoCaptureController.stopCapture()
}
}
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
if shouldHaveLocalVideoTrack {
call.videoCaptureController.startCapture()
} else {
call.videoCaptureController.stopCapture()
}
}
}
// MARK: -
func buildAndConnectGroupCall(for thread: TSGroupThread, isVideoMuted: Bool) -> (SignalCall, GroupThreadCall)? {
owsAssertDebug(thread.groupModel.groupsVersion == .V2)
return _buildAndConnectGroupCall(isOutgoingVideoMuted: isVideoMuted) { () -> (SignalCall, GroupThreadCall)? in
let videoCaptureController = VideoCaptureController()
let sfuUrl = DebugFlags.callingUseTestSFU.get() ? TSConstants.sfuTestURL : TSConstants.sfuURL
let ringRtcCall = callManager.createGroupCall(
groupId: thread.groupModel.groupId,
sfuUrl: sfuUrl,
hkdfExtraInfo: Data(),
audioLevelsIntervalMillis: nil,
videoCaptureController: videoCaptureController
)
guard let ringRtcCall else {
return nil
}
let groupThreadCall = GroupThreadCall(
delegate: self,
ringRtcCall: ringRtcCall,
groupThread: thread,
videoCaptureController: videoCaptureController
)
return (SignalCall(groupThreadCall: groupThreadCall), groupThreadCall)
}
}
@MainActor
func buildAndConnectCallLinkCall(callLink: CallLink) async throws -> (SignalCall, CallLinkCall)? {
let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!
let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: localIdentifiers)
let callLinkState = try await callLinkManager.readCallLink(callLink.rootKey, authCredential: authCredential)
return _buildAndConnectGroupCall(isOutgoingVideoMuted: false) { () -> (SignalCall, CallLinkCall)? in
// [CallLink] TODO: Provide adminPasskey.
let videoCaptureController = VideoCaptureController()
let sfuUrl = DebugFlags.callingUseTestSFU.get() ? TSConstants.sfuTestURL : TSConstants.sfuURL
let secretParams = CallLinkSecretParams.deriveFromRootKey(callLink.rootKey.bytes)
let authCredentialPresentation = authCredential.present(callLinkParams: secretParams)
let ringRtcCall = callManager.createCallLinkCall(
sfuUrl: sfuUrl,
authCredentialPresentation: authCredentialPresentation.serialize(),
linkRootKey: callLink.rootKey,
adminPasskey: nil,
hkdfExtraInfo: Data(),
audioLevelsIntervalMillis: nil,
videoCaptureController: videoCaptureController
)
guard let ringRtcCall else {
return nil
}
let callLinkCall = CallLinkCall(
callLink: callLink,
adminPasskey: nil,
callLinkState: callLinkState,
ringRtcCall: ringRtcCall,
videoCaptureController: videoCaptureController
)
return (SignalCall(callLinkCall: callLinkCall), callLinkCall)
}
}
private func _buildAndConnectGroupCall<T: GroupCall>(
isOutgoingVideoMuted: Bool,
createCall: () -> (SignalCall, T)?
) -> (SignalCall, T)? {
AssertIsOnMainThread()
guard callServiceState.currentCall == nil else { return nil }
guard let (call, groupCall) = createCall() else {
owsFailDebug("Failed to create call")
return nil
}
// By default, group calls should start out with speakerphone enabled.
self.audioService.requestSpeakerphone(isEnabled: true)
groupCall.ringRtcCall.isOutgoingAudioMuted = false
groupCall.ringRtcCall.isOutgoingVideoMuted = isOutgoingVideoMuted
callServiceState.setCurrentCall(call)
// Connect (but don't join) to subscribe to live updates.
guard connectGroupCallIfNeeded(groupCall) else {
callServiceState.terminateCall(call)
return nil
}
return (call, groupCall)
}
func joinGroupCallIfNecessary(_ call: SignalCall, groupCall: GroupCall) {
guard call === self.callServiceState.currentCall else {
owsFailDebug("Can't join a group call if it's not the current call")
return
}
// If we're disconnected, it means we hit an error with the first
// connection, so connect now. (Ex: You try to join a call that's full, and
// then you try to join again.)
guard connectGroupCallIfNeeded(groupCall) else {
owsFailDebug("Can't join a group call if we can't connect()")
return
}
// If we're not yet joined, join now. In general, it's unexpected that
// this method would be called when you're already joined, but it is
// safe to do so.
let ringRtcCall = groupCall.ringRtcCall
if ringRtcCall.localDeviceState.joinState == .notJoined {
ringRtcCall.join()
// Group calls can get disconnected, but we don't count that as ending the call.
// So this call may have already been reported.
if groupCall.commonState.systemState == .notReported {
callUIAdapter.startOutgoingCall(call: call)
}
}
}
private func connectGroupCallIfNeeded(_ groupCall: GroupCall) -> Bool {
if groupCall.hasInvokedConnectMethod {
return true
}
// If we haven't invoked the method, we shouldn't be connected. (Note: The
// converse is NOT true, and that's why we need `hasInvokedConnectMethod`.)
owsAssertDebug(groupCall.ringRtcCall.localDeviceState.connectionState == .notConnected)
let result = groupCall.ringRtcCall.connect()
if result {
groupCall.hasInvokedConnectMethod = true
}
return result
}
/// Leaves the group call & schedules it for termination.
///
/// If the call has already "ended" (RingRTC term), perhaps because we
/// encountered an error, it will terminate the group call immediately.
///
/// We wait for the call to end before terminating to ensure that observers
/// have an opportunity to handle the "call ended" event.
private func leaveAndTerminateGroupCall(_ call: SignalCall, groupCall: GroupCall) {
if groupCall.hasInvokedConnectMethod {
groupCall.ringRtcCall.disconnect()
groupCall.shouldTerminateOnEndEvent = true
} else {
callServiceState.terminateCall(call)
}
}
func initiateCall(to callTarget: CallTarget, isVideo: Bool) {
switch callTarget {
case .individual(let contactThread):
Task { await self.initiateIndividualCall(thread: contactThread, isVideo: isVideo) }
case .groupThread(let groupThread):
GroupCallViewController.presentLobby(thread: groupThread, videoMuted: !isVideo)
case .callLink(let callLink):
GroupCallViewController.presentLobby(for: callLink)
}
}
@MainActor
private func initiateIndividualCall(thread: TSContactThread, isVideo: Bool) async {
let untrustedThreshold = Date(timeIntervalSinceNow: -OWSIdentityManagerImpl.Constants.defaultUntrustedInterval)
guard let viewController = await CallStarter.prepareToStartCall(shouldAskForCameraPermission: isVideo) else {
return
}
guard await SafetyNumberConfirmationSheet.presentRepeatedlyAsNecessary(
for: { [thread.contactAddress] },
from: viewController,
confirmationText: CallStrings.confirmAndCallButtonTitle,
untrustedThreshold: untrustedThreshold
) else {
return
}
self.callUIAdapter.startAndShowOutgoingCall(thread: thread, hasLocalVideo: isVideo)
}
func buildOutgoingIndividualCallIfPossible(thread: TSContactThread, hasVideo: Bool) -> (SignalCall, IndividualCall)? {
AssertIsOnMainThread()
guard callServiceState.currentCall == nil else { return nil }
let individualCall = IndividualCall.outgoingIndividualCall(
thread: thread,
offerMediaType: hasVideo ? .video : .audio
)
let call = SignalCall(individualCall: individualCall)
return (call, individualCall)
}
// MARK: - Notifications
@objc
private func didEnterBackground() {
AssertIsOnMainThread()
self.updateIsVideoEnabled()
}
@objc
private func didBecomeActive() {
AssertIsOnMainThread()
self.updateIsVideoEnabled()
}
@objc
private func registrationChanged() {
AssertIsOnMainThread()
if let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci {
callManager.setSelfUuid(localAci.rawUUID)
}
}
/// The object is the rotation angle necessary to match the new orientation.
static var phoneOrientationDidChange = Notification.Name("CallService.phoneOrientationDidChange")
@objc
private func phoneOrientationDidChange() {
guard callServiceState.currentCall != nil else {
return
}
sendPhoneOrientationNotification()
}
private func shouldReorientUI(for call: SignalCall) -> Bool {
owsAssertDebug(!UIDevice.current.isIPad, "iPad has full UIKit rotation support")
switch call.mode {
case .individual(let individualCall):
// If we're in an audio-only 1:1 call, the user isn't going to be looking at the screen.
// Don't distract them with rotating icons.
return individualCall.hasLocalVideo || individualCall.isRemoteVideoEnabled
case .groupThread, .callLink:
// If we're in a group call, we don't want to use rotating icons because we
// don't rotate user video at the same time, and that's very obvious for
// grid view or any non-speaker tile in speaker view.
return false
}
}
private func sendPhoneOrientationNotification() {
owsAssertDebug(!UIDevice.current.isIPad, "iPad has full UIKit rotation support")
let rotationAngle: CGFloat
if let call = callServiceState.currentCall, !shouldReorientUI(for: call) {
// We still send the notification in case we *previously* rotated the UI and now we need to revert back.
// Example:
// 1. In a 1:1 call, either the user or their contact (but not both) has video on
// 2. the user has the phone in landscape
// 3. whoever had video turns it off (but the icons are still landscape-oriented)
// 4. the user rotates back to portrait
rotationAngle = 0
} else {
switch UIDevice.current.orientation {
case .landscapeLeft:
rotationAngle = .halfPi
case .landscapeRight:
rotationAngle = -.halfPi
case .portrait, .portraitUpsideDown, .faceDown, .faceUp, .unknown:
fallthrough
@unknown default:
rotationAngle = 0
}
}
NotificationCenter.default.post(name: Self.phoneOrientationDidChange, object: rotationAngle)
}
/// Pretend the phone just changed orientations so that the call UI will autorotate.
func sendInitialPhoneOrientationNotification() {
guard !UIDevice.current.isIPad else {
return
}
sendPhoneOrientationNotification()
}
// MARK: -
private func updateGroupMembersForCurrentCallIfNecessary() {
DispatchQueue.main.async {
let currentCall = self.callServiceState.currentCall
guard let groupThreadCall = currentCall?.unpackGroupCall() else {
return
}
let membershipInfo: [GroupMemberInfo]
do {
membershipInfo = try self.databaseStorage.read { tx in
try self.groupCallManager.groupCallPeekClient.groupMemberInfo(
groupThread: groupThreadCall.groupThread, tx: tx.asV2Read
)
}
} catch {
owsFailDebug("Failed to fetch membership info: \(error)")
return
}
// [CallLink] TODO: .
groupThreadCall.ringRtcCall.updateGroupMembers(members: membershipInfo)
}
}
// MARK: - Data Modes
static let callServicePreferencesDidChange = Notification.Name("CallServicePreferencesDidChange")
private static let keyValueStore = SDSKeyValueStore(collection: "CallService")
// This used to be called "high bandwidth", but "data" is more accurate.
private static let highDataPreferenceKey = "HighBandwidthPreferenceKey"
static func setHighDataInterfaces(_ interfaceSet: NetworkInterfaceSet, writeTx: SDSAnyWriteTransaction) {
Logger.info("Updating preferred low data interfaces: \(interfaceSet.rawValue)")
keyValueStore.setUInt(interfaceSet.rawValue, key: highDataPreferenceKey, transaction: writeTx)
writeTx.addSyncCompletion {
NotificationCenter.default.postNotificationNameAsync(callServicePreferencesDidChange, object: nil)
}
}
static func highDataNetworkInterfaces(readTx: SDSAnyReadTransaction) -> NetworkInterfaceSet {
guard let highDataPreference = keyValueStore.getUInt(
highDataPreferenceKey,
transaction: readTx) else { return .wifiAndCellular }
return NetworkInterfaceSet(rawValue: highDataPreference)
}
}
extension CallService: IndividualCallObserver {
func individualCallStateDidChange(_ call: IndividualCall, state: CallState) {
AssertIsOnMainThread()
updateIsVideoEnabled()
configureDataMode()
}
func individualCallLocalVideoMuteDidChange(_ call: IndividualCall, isVideoMuted: Bool) {
AssertIsOnMainThread()
updateIsVideoEnabled()
}
}
extension CallService: GroupCallObserver {
func groupCallLocalDeviceStateChanged(_ call: GroupCall) {
AssertIsOnMainThread()
let ringRtcCall = call.ringRtcCall
Logger.info("")
updateIsVideoEnabled()
configureDataMode()
switch call.concreteType {
case .groupThread(let call):
updateGroupMembersForCurrentCallIfNecessary()
if
ringRtcCall.localDeviceState.isJoined,
case .shouldRing = call.groupCallRingState,
call.ringRestrictions.isEmpty,
ringRtcCall.remoteDeviceStates.isEmpty
{
// Don't start ringing until we join the call successfully.
call.groupCallRingState = .ringing
ringRtcCall.ringAll()
audioService.playOutboundRing()
}
let groupThread = call.groupThread
if ringRtcCall.localDeviceState.isJoined {
if let eraId = ringRtcCall.peekInfo?.eraId {
groupCallAccessoryMessageDelegate.localDeviceMaybeJoinedGroupCall(
eraId: eraId,
groupThread: groupThread,
groupCallRingState: call.groupCallRingState
)
}
} else {
groupCallAccessoryMessageDelegate.localDeviceMaybeLeftGroupCall(
groupThread: groupThread,
groupCall: ringRtcCall
)
}
case .callLink:
// [CallLink] TODO: Bump the call record in the calls tab.
break
}
}
func groupCallPeekChanged(_ call: GroupCall) {
AssertIsOnMainThread()
let ringRtcCall = call.ringRtcCall
guard let peekInfo = ringRtcCall.peekInfo else {
Logger.warn("No peek info for call: \(call)")
return
}
switch call.concreteType {
case .groupThread(let call):
let groupThread = call.groupThread
if
ringRtcCall.localDeviceState.isJoined,
let eraId = peekInfo.eraId
{
groupCallAccessoryMessageDelegate.localDeviceMaybeJoinedGroupCall(
eraId: eraId,
groupThread: groupThread,
groupCallRingState: call.groupCallRingState
)
}
databaseStorage.asyncWrite { tx in
self.groupCallManager.updateGroupCallModelsForPeek(
peekInfo: peekInfo,
groupThread: groupThread,
triggerEventTimestamp: Date.ows_millisecondTimestamp(),
tx: tx
)
}
case .callLink:
// [CallLink] TODO: Bump the call record in the calls tab if needed.
break
}
}
func groupCallEnded(_ groupCall: GroupCall, reason: GroupCallEndReason) {
AssertIsOnMainThread()
groupCallAccessoryMessageDelegate.localDeviceGroupCallDidEnd()
let call = callServiceState.currentCall
switch call?.mode {
case nil, .individual:
owsFail("Can't receive callback without an active group call")
case .groupThread(let currentCall as GroupCall), .callLink(let currentCall as GroupCall):
owsAssert(currentCall === groupCall)
if currentCall.shouldTerminateOnEndEvent {
callServiceState.terminateCall(call!)
}
}
}
public func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) {
switch call.concreteType {
case .groupThread(let call):
if
case .ringing = call.groupCallRingState,
!call.ringRtcCall.remoteDeviceStates.isEmpty
{
// The first time someone joins after a ring, we need to mark the call accepted.
// (But if we didn't ring, the call will have already been marked accepted.)
callUIAdapter.recipientAcceptedCall(.groupThread(call))
}
case .callLink:
break
}
}
}
extension CallService: GroupThreadCallDelegate {
func groupThreadCallRequestMembershipProof(_ call: GroupThreadCall) {
Logger.info("")
let groupCall = call.ringRtcCall
let groupThread = call.groupThread
Task { [groupCallManager] in
do {
let proof = try await groupCallManager.groupCallPeekClient.fetchGroupMembershipProof(groupThread: groupThread)
await MainActor.run {
groupCall.updateMembershipProof(proof: proof)
}
} catch {
if error.isNetworkFailureOrTimeout {
Logger.warn("Failed to fetch group call credentials \(error)")
} else {
owsFailDebug("Failed to fetch group call credentials \(error)")
}
}
}
}
func groupThreadCallRequestGroupMembers(_ call: GroupThreadCall) {
Logger.info("")
updateGroupMembersForCurrentCallIfNecessary()
}
}
extension SignalCall {
func unpackGroupCall() -> GroupThreadCall? {
switch mode {
case .individual:
return nil
case .groupThread(let groupThreadCall):
return groupThreadCall
case .callLink:
return nil
}
}
}
private extension LocalDeviceState {
var isJoined: Bool {
switch joinState {
case .joined: return true
case .pending, .joining, .notJoined: return false
}
}
}
// MARK: - Group call participant updates
extension CallService: DatabaseChangeDelegate {
public func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
AssertIsOnMainThread()
owsAssertDebug(AppReadiness.isAppReady)
switch callServiceState.currentCall?.mode {
case nil, .individual, .callLink:
break
case .groupThread(let groupThreadCall):
if databaseChanges.didUpdate(thread: groupThreadCall.groupThread) {
updateGroupMembersForCurrentCallIfNecessary()
}
}
}
public func databaseChangesDidUpdateExternally() {
AssertIsOnMainThread()
owsAssertDebug(AppReadiness.isAppReady)
updateGroupMembersForCurrentCallIfNecessary()
}
public func databaseChangesDidReset() {
AssertIsOnMainThread()
owsAssertDebug(AppReadiness.isAppReady)
updateGroupMembersForCurrentCallIfNecessary()
}
}
extension CallService: CallManagerDelegate {
public typealias CallManagerDelegateCallType = SignalCall
/**
* Send a generic call message to the given remote recipient.
* Invoked on the main thread, asynchronously.
* If there is any error, the UI can reset UI state and invoke the reset() API.
*/
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendCallMessage recipientUuid: UUID,
message: Data,
urgency: CallMessageUrgency
) {
AssertIsOnMainThread()
Logger.info("")
let recipientAci = Aci(fromUUID: recipientUuid)
let currentCall = self.callServiceState.currentCall
databaseStorage.write(.promise) { transaction in
TSContactThread.getOrCreateThread(
withContactAddress: SignalServiceAddress(recipientAci),
transaction: transaction
)
}.then(on: DispatchQueue.global()) { thread -> Promise<Void> in
let opaqueBuilder = SSKProtoCallMessageOpaque.builder()
opaqueBuilder.setData(message)
opaqueBuilder.setUrgency(urgency.protobufValue)
return self.databaseStorage.write { transaction in
let callMessage = OWSOutgoingCallMessage(
thread: thread,
opaqueMessage: opaqueBuilder.buildInfallibly(),
overrideRecipients: nil,
transaction: transaction
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: callMessage
)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
limitToCurrentProcessLifetime: true,
isHighPriority: true,
transaction: transaction
)
}
}.done(on: DispatchQueue.main) { _ in
// TODO: Tell RingRTC we succeeded in sending the message. API TBD
}.catch(on: DispatchQueue.main) { error in
if error.isNetworkFailureOrTimeout {
Logger.warn("Failed to send opaque message \(error)")
} else if error is UntrustedIdentityError {
switch currentCall?.mode {
case nil:
owsFailDebug("We can't send messages to inactive group calls.")
case .individual:
owsFailDebug("We don't send messages for 1:1 calls.")
case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
// [CallLink] TODO: Check if this callback is used.
call.publishSendFailureUntrustedParticipantIdentity()
}
} else {
Logger.error("Failed to send opaque message \(error)")
}
// TODO: Tell RingRTC something went wrong. API TBD
}
}
/**
* Send a generic call message to a group. Send to all members of the group
* or, if overrideRecipients is not empty, send to the given subset of members
* using multi-recipient sealed sender. If the sealed sender request fails,
* clients should provide a fallback mechanism.
* Invoked on the main thread, asynchronously.
* If there is any error, the UI can reset UI state and invoke the reset() API.
*/
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendCallMessageToGroup groupId: Data,
message: Data,
urgency: CallMessageUrgency,
overrideRecipients: [UUID]
) {
AssertIsOnMainThread()
Logger.info("")
databaseStorage.read(.promise) { transaction throws -> TSGroupThread in
guard let thread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw OWSAssertionError("tried to send call message to unknown group")
}
return thread
}.then(on: DispatchQueue.global()) { thread -> Promise<Void> in
let opaqueBuilder = SSKProtoCallMessageOpaque.builder()
opaqueBuilder.setData(message)
opaqueBuilder.setUrgency(urgency.protobufValue)
return self.databaseStorage.write { transaction in
let overrideRecipients = overrideRecipients.map {
return AciObjC(Aci(fromUUID: $0))
}
let callMessage = OWSOutgoingCallMessage(
thread: thread,
opaqueMessage: opaqueBuilder.buildInfallibly(),
overrideRecipients: overrideRecipients.isEmpty ? nil : overrideRecipients,
transaction: transaction
)
let preparedMessage = PreparedOutgoingMessage.preprepared(
transientMessageWithoutAttachments: callMessage
)
return ThreadUtil.enqueueMessagePromise(
message: preparedMessage,
limitToCurrentProcessLifetime: true,
isHighPriority: true,
transaction: transaction
)
}
}.done(on: DispatchQueue.main) { _ in
// TODO: Tell RingRTC we succeeded in sending the message. API TBD
}.catch(on: DispatchQueue.main) { error in
if error.isNetworkFailureOrTimeout {
Logger.warn("Failed to send opaque message \(error)")
} else {
Logger.error("Failed to send opaque message \(error)")
}
// TODO: Tell RingRTC something went wrong. API TBD
}
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldCompareCalls call1: SignalCall,
call2: SignalCall
) -> Bool {
Logger.info("")
guard case .individual(let call1) = call1.mode else {
owsFailDebug("Can't compare multi-participant calls.")
return false
}
guard case .individual(let call2) = call2.mode else {
owsFailDebug("Can't compare multi-participant calls.")
return false
}
return call1.thread.uniqueId == call2.thread.uniqueId
}
// MARK: - 1:1 Call Delegates
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldStartCall call: SignalCall,
callId: UInt64,
isOutgoing: Bool,
callMediaType: CallMediaType
) {
AssertIsOnMainThread()
guard callServiceState.currentCall == nil else {
handleFailedCall(failedCall: call, error: OWSGenericError("a current call is already set"))
return
}
switch call.mode {
case .individual(let individualCall) where isOutgoing:
individualCall.setOutgoingCallIdAndUpdateCallRecord(callId)
case .individual:
break
case .groupThread, .callLink:
owsFail("Can't start a group call using this method.")
}
// We grab this before updating the currentCall since it will unset it by default as a precaution.
let shouldEarlyRing = earlyRingNextIncomingCall.swap(false) && !isOutgoing
// The call to be started is provided by the event.
callServiceState.setCurrentCall(call)
individualCallService.callManager(
callManager,
shouldStartCall: call,
callId: callId,
isOutgoing: isOutgoing,
callMediaType: callMediaType,
shouldEarlyRing: shouldEarlyRing
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
onEvent call: SignalCall,
event: CallManagerEvent
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
onEvent: call,
event: event
)
}
/**
* onNetworkRouteChangedFor will be invoked when changes to the network routing (e.g. wifi/cellular) are detected.
* Invoked on the main thread, asynchronously.
*/
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
onNetworkRouteChangedFor call: SignalCall,
networkRoute: NetworkRoute
) {
Logger.info("Network route changed for call: \(call): \(networkRoute.localAdapterType.rawValue)")
switch call.mode {
case .individual(let individualCall):
individualCall.networkRoute = networkRoute
configureDataMode()
case .groupThread, .callLink:
owsFail("Can't set the network route for a group call.")
}
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
onAudioLevelsFor call: SignalCall,
capturedLevel: UInt16,
receivedLevel: UInt16
) {
// TODO: Implement audio level handling for individual calls.
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
onLowBandwidthForVideoFor call: SignalCall,
recovered: Bool
) {
// TODO: Implement handling of the "low outgoing bandwidth for video" notification.
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendOffer callId: UInt64,
call: SignalCall,
destinationDeviceId: UInt32?,
opaque: Data,
callMediaType: CallMediaType
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
shouldSendOffer: callId,
call: call,
destinationDeviceId: destinationDeviceId,
opaque: opaque,
callMediaType: callMediaType
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendAnswer callId: UInt64,
call: SignalCall,
destinationDeviceId: UInt32?,
opaque: Data
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
shouldSendAnswer: callId,
call: call,
destinationDeviceId: destinationDeviceId,
opaque: opaque
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendIceCandidates callId: UInt64,
call: SignalCall,
destinationDeviceId: UInt32?,
candidates: [Data]
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
shouldSendIceCandidates: callId,
call: call,
destinationDeviceId: destinationDeviceId,
candidates: candidates
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendHangup callId: UInt64,
call: SignalCall,
destinationDeviceId: UInt32?,
hangupType: HangupType,
deviceId: UInt32
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
shouldSendHangup: callId,
call: call,
destinationDeviceId: destinationDeviceId,
hangupType: hangupType,
deviceId: deviceId
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
shouldSendBusy callId: UInt64,
call: SignalCall,
destinationDeviceId: UInt32?
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
shouldSendBusy: callId,
call: call,
destinationDeviceId: destinationDeviceId
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
onUpdateLocalVideoSession call: SignalCall,
session: AVCaptureSession?
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
onUpdateLocalVideoSession: call,
session: session
)
}
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
onAddRemoteVideoTrack call: SignalCall,
track: RTCVideoTrack
) {
AssertIsOnMainThread()
individualCallService.callManager(
callManager,
onAddRemoteVideoTrack: call,
track: track
)
}
/**
* An update from `sender` has come in for the ring in `groupId` identified by `ringId`.
*
* `sender` will be the current user's ID if the update came from another device.
*
* Invoked on the main thread, asynchronously.
*/
public func callManager(
_ callManager: CallManager<SignalCall, CallService>,
didUpdateRingForGroup groupId: Data,
ringId: Int64,
sender: UUID,
update: RingUpdate
) {
let senderAci = Aci(fromUUID: sender)
/// Let our ``CallRecord`` delegate know we got a ring update.
databaseStorage.asyncWrite { tx in
self.groupCallRecordRingUpdateDelegate.didReceiveRingUpdate(
groupId: groupId,
ringId: ringId,
ringUpdate: update,
ringUpdateSender: senderAci,
tx: tx.asV2Write
)
}
guard update == .requested else {
if
let currentCall = self.callServiceState.currentCall,
case .groupThread(let groupThreadCall) = currentCall.mode,
case .incomingRing(_, ringId) = groupThreadCall.groupCallRingState
{
switch update {
case .requested:
owsFail("checked above")
case .expiredRing:
self.callUIAdapter.remoteDidHangupCall(currentCall)
case .acceptedOnAnotherDevice:
self.callUIAdapter.didAnswerElsewhere(call: currentCall)
case .declinedOnAnotherDevice:
self.callUIAdapter.didDeclineElsewhere(call: currentCall)
case .busyLocally:
owsFailDebug("shouldn't get reported here")
fallthrough
case .busyOnAnotherDevice:
self.callUIAdapter.wasBusyElsewhere(call: currentCall)
case .cancelledByRinger:
self.callUIAdapter.remoteDidHangupCall(currentCall)
}
self.leaveAndTerminateGroupCall(currentCall, groupCall: groupThreadCall)
groupThreadCall.groupCallRingState = .incomingRingCancelled
}
databaseStorage.asyncWrite { transaction in
do {
try CancelledGroupRing(id: ringId).insert(transaction.unwrapGrdbWrite.database)
try CancelledGroupRing.deleteExpired(expiration: Date().addingTimeInterval(-30 * kMinuteInterval),
transaction: transaction)
} catch {
owsFailDebug("failed to update cancellation table: \(error)")
}
}
return
}
let caller = SignalServiceAddress(senderAci)
enum RingAction {
case cancel
case ring(TSGroupThread)
}
let action: RingAction = databaseStorage.read { transaction in
guard let thread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
owsFailDebug("discarding group ring \(ringId) from \(senderAci) for unknown group")
return .cancel
}
guard GroupsV2MessageProcessor.discardMode(
forMessageFrom: caller,
groupId: groupId,
tx: transaction
) == .doNotDiscard else {
Logger.warn("discarding group ring \(ringId) from \(senderAci)")
return .cancel
}
guard thread.groupMembership.fullMembers.count <= RemoteConfig.maxGroupCallRingSize else {
Logger.warn("discarding group ring \(ringId) from \(senderAci) for too-large group")
return .cancel
}
do {
if try CancelledGroupRing.exists(transaction.unwrapGrdbRead.database, key: ringId) {
return .cancel
}
} catch {
owsFailDebug("unable to check cancellation table: \(error)")
}
return .ring(thread)
}
switch action {
case .cancel:
do {
try callManager.cancelGroupRing(groupId: groupId, ringId: ringId, reason: nil)
} catch {
owsFailDebug("RingRTC failed to cancel group ring \(ringId): \(error)")
}
case .ring(let thread):
let currentCall = self.callServiceState.currentCall
if case .groupThread(let call) = currentCall?.mode, call.groupThread.uniqueId == thread.uniqueId {
// We're already ringing or connected, or at the very least already in the lobby.
return
}
guard currentCall == nil else {
do {
try callManager.cancelGroupRing(groupId: groupId, ringId: ringId, reason: .busy)
} catch {
owsFailDebug("RingRTC failed to cancel group ring \(ringId): \(error)")
}
return
}
// Mute video by default unless the user has already approved it.
// This keeps us from popping the "give permission to use your camera" alert before the user answers.
let videoMuted = AVCaptureDevice.authorizationStatus(for: .video) != .authorized
guard let (call, groupThreadCall) = buildAndConnectGroupCall(
for: thread,
isVideoMuted: videoMuted
) else {
return owsFailDebug("Failed to build group call")
}
groupThreadCall.groupCallRingState = .incomingRing(caller: caller, ringId: ringId)
self.callUIAdapter.reportIncomingCall(call)
}
}
}
extension CallMessageUrgency {
var protobufValue: SSKProtoCallMessageOpaqueUrgency {
switch self {
case .droppable: return .droppable
case .handleImmediately: return .handleImmediately
}
}
}
extension NetworkInterfaceSet {
func includes(_ ringRtcAdapter: NetworkAdapterType) -> Bool? {
switch ringRtcAdapter {
case .unknown, .vpn, .anyAddress:
if self.isEmpty {
return false
} else if self.inverted.isEmpty {
return true
} else {
// We don't know the underlying interface, so we can't assume anything.
return nil
}
case .cellular, .cellular2G, .cellular3G, .cellular4G, .cellular5G:
return self.contains(.cellular)
case .ethernet, .wifi, .loopback:
return self.contains(.wifi)
}
}
}