diff --git a/Signal/src/Calls/CallAudioService.swift b/Signal/src/Calls/CallAudioService.swift index bc203091ff..27088d5f45 100644 --- a/Signal/src/Calls/CallAudioService.swift +++ b/Signal/src/Calls/CallAudioService.swift @@ -175,7 +175,7 @@ protocol CallAudioServiceDelegate: AnyObject { return } - if call.state == .localRinging { + if [.localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting].contains(call.state) { // The AudioSession for playing a ring tone. setAudioSession(category: .playback, mode: .default) } else if call.hasLocalVideo { @@ -210,7 +210,8 @@ protocol CallAudioServiceDelegate: AnyObject { case .dialing: handleDialing(call: call) case .answering: handleAnswering(call: call) case .remoteRinging: handleRemoteRinging(call: call) - case .localRinging: handleLocalRinging(call: call) + case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting: + handleLocalRinging(call: call) case .connected: handleConnected(call: call) case .reconnecting: handleReconnecting(call: call) case .localFailure: handleLocalFailure(call: call) @@ -370,6 +371,9 @@ protocol CallAudioServiceDelegate: AnyObject { return } + // Only CallKit calls should be in the transitory ringing states + owsAssertDebug(call.state == .localRinging_ReadyToAnswer) + vibrateTimer?.invalidate() vibrateTimer = .scheduledTimer(withTimeInterval: vibrateRepeatDuration, repeats: true) { [weak self] _ in self?.ringVibration() diff --git a/Signal/src/Calls/CallService.swift b/Signal/src/Calls/CallService.swift index 74c01272dd..8ffb030804 100644 --- a/Signal/src/Calls/CallService.swift +++ b/Signal/src/Calls/CallService.swift @@ -28,6 +28,8 @@ public final class CallService: NSObject { lazy private(set) var audioService = CallAudioService() + public var earlyRingNextIncomingCall = false + private var _currentCall: SignalCall? @objc public private(set) var currentCall: SignalCall? { @@ -58,6 +60,9 @@ public final class CallService: NSObject { } } + // To be safe, we reset the early ring on any call change so it's not left set from an unexpected state change + earlyRingNextIncomingCall = false + Logger.debug("\(oldValue as Optional) -> \(newValue as Optional)") let observers = self.observers @@ -1062,6 +1067,10 @@ extension CallService: CallManagerDelegate { call.individualCall.callId = callId + // We grab this before updating the currentCall since it will unset it by default as a precaution. + let shouldEarlyRing = earlyRingNextIncomingCall && !isOutgoing + earlyRingNextIncomingCall = false + // The call to be started is provided by the event. currentCall = call @@ -1070,7 +1079,8 @@ extension CallService: CallManagerDelegate { shouldStartCall: call, callId: callId, isOutgoing: isOutgoing, - callMediaType: callMediaType + callMediaType: callMediaType, + shouldEarlyRing: shouldEarlyRing ) } diff --git a/Signal/src/Calls/Individual/IndividualCall.swift b/Signal/src/Calls/Individual/IndividualCall.swift index 3b87e9e1ab..25fc850872 100644 --- a/Signal/src/Calls/Individual/IndividualCall.swift +++ b/Signal/src/Calls/Individual/IndividualCall.swift @@ -11,7 +11,14 @@ public enum CallState: String { case dialing case answering case remoteRinging - case localRinging + + // The local ringing state is a bit more complex since we sometimes kick off + // a CallKit ring before RingRTC is ready to answer. We can only answer the call + // once both the user has answered and RingRTC is ready. + case localRinging_Anticipatory // RingRTC not ready. User has not answered + case localRinging_ReadyToAnswer // RingRTC ready. User has not answered + case accepting // RingRTC not ready. User has answered + case connected case reconnecting case localFailure // terminal @@ -109,7 +116,7 @@ public class IndividualCall: NSObject, IndividualCallNotificationInfo { switch state { case .localFailure, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteBusy, .answeredElsewhere, .declinedElsewhere, .busyElsewhere: return true - case .idle, .dialing, .answering, .remoteRinging, .localRinging, .connected, .reconnecting: + case .idle, .dialing, .answering, .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting, .connected, .reconnecting: return false } } @@ -141,6 +148,12 @@ public class IndividualCall: NSObject, IndividualCallNotificationInfo { } } + var deferredAnswerCompletion: (() -> Void)? { + didSet { + owsAssertDebug(deferredAnswerCompletion == nil || state == .accepting) + } + } + public var state: CallState { didSet { AssertIsOnMainThread() diff --git a/Signal/src/Calls/Individual/IndividualCallService.swift b/Signal/src/Calls/Individual/IndividualCallService.swift index 5342020cde..8fb2f2652e 100644 --- a/Signal/src/Calls/Individual/IndividualCallService.swift +++ b/Signal/src/Calls/Individual/IndividualCallService.swift @@ -92,6 +92,13 @@ import SignalMessaging AssertIsOnMainThread() 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 callService.currentCall === call else { let error = OWSAssertionError("accepting call: \(call) which is different from currentCall: \(callService.currentCall as Optional)") handleFailedCall(failedCall: call, error: error) @@ -148,7 +155,7 @@ import SignalMessaging if callRecord.callType == .outgoingIncomplete { callRecord.updateCallType(.outgoingMissed) } - } else if call.individualCall.state == .localRinging { + } else if [.localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(call.individualCall.state) { let callRecord = TSCall( callType: .incomingDeclined, offerType: call.individualCall.offerMediaType, @@ -503,11 +510,23 @@ import SignalMessaging // MARK: - Call Manager Events - public func callManager(_ callManager: CallService.CallManagerType, shouldStartCall call: SignalCall, callId: UInt64, isOutgoing: Bool, callMediaType: CallMediaType) { + public func callManager(_ callManager: CallService.CallManagerType, shouldStartCall call: SignalCall, callId: UInt64, isOutgoing: Bool, callMediaType: CallMediaType, shouldEarlyRing: Bool) { AssertIsOnMainThread() owsAssertDebug(call.isIndividualCall) 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. + owsAssertDebug(callUIAdapter.adaptee(for: call) === callUIAdapter.callKitAdaptee) + Logger.info("Performing early ring") + handleRinging(call: call, isAnticipatory: true) + } else { + owsFailDebug("Cannot early ring an outgoing call") + } + } + // Start the call, asynchronously. getIceServers().done(on: .main) { iceServers in guard self.callService.currentCall === call else { @@ -579,7 +598,7 @@ import SignalMessaging audioSession.isRTCAudioEnabled = false switch call.individualCall.state { - case .idle, .dialing, .answering, .localRinging, .localFailure, .remoteBusy, .remoteRinging: + 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") @@ -601,7 +620,7 @@ import SignalMessaging audioSession.isRTCAudioEnabled = false switch call.individualCall.state { - case .idle, .dialing, .answering, .localRinging, .localFailure, .remoteBusy, .remoteRinging: + 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") @@ -626,10 +645,10 @@ import SignalMessaging 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: + case .answering, .accepting, .connected: Logger.info("tried answering locally, but answered somewhere else first. state: \(call.individualCall.state)") handleAnsweredElsewhere(call: call) - case .localRinging, .reconnecting: + case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting: handleAnsweredElsewhere(call: call) case .localFailure, .localHangup: Logger.info("ignoring 'endedRemoteHangupAccepted' since call is already finished") @@ -647,10 +666,10 @@ import SignalMessaging 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: + case .answering, .accepting, .connected: Logger.info("tried answering locally, but declined somewhere else first. state: \(call.individualCall.state)") handleDeclinedElsewhere(call: call) - case .localRinging, .reconnecting: + case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting: handleDeclinedElsewhere(call: call) case .localFailure, .localHangup: Logger.info("ignoring 'endedRemoteHangupDeclined' since call is already finished") @@ -668,10 +687,10 @@ import SignalMessaging 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: + 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, .reconnecting: + case .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .reconnecting: handleBusyElsewhere(call: call) case .localFailure, .localHangup: Logger.info("ignoring 'endedRemoteHangupBusy' since call is already finished") @@ -1145,13 +1164,22 @@ import SignalMessaging } /** - * The clients can now communicate via WebRTC, so we can let the UI know. + * Present UI to begin ringing. * - * Called by both caller and callee. Compatible ICE messages have been exchanged between the local and remote - * client. + * 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. */ - private func handleRinging(call: SignalCall) { + private func handleRinging(call: SignalCall, isAnticipatory: Bool = false) { AssertIsOnMainThread() + // Only incoming calls can use the early ring states + owsAssertDebug(!(call.individualCall.direction == .outgoing && isAnticipatory)) Logger.info("call: \(call)") guard call === callService.currentCall else { @@ -1161,19 +1189,23 @@ import SignalMessaging switch call.individualCall.state { case .dialing: - if call.individualCall.state != .remoteRinging { - BenchEventComplete(eventId: "call-\(call.individualCall.localId)") - } + 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 + BenchEventComplete(eventId: "call-\(call.individualCall.localId)") + call.individualCall.state = isAnticipatory ? .localRinging_Anticipatory : .localRinging_ReadyToAnswer self.callUIAdapter.reportIncomingCall(call, thread: call.individualCall.thread) + 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, .localRinging, .connected, .reconnecting, .localFailure, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteBusy, .answeredElsewhere, .declinedElsewhere, .busyElsewhere: + case .idle, .connected, .reconnecting, .localFailure, .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteBusy, .answeredElsewhere, .declinedElsewhere, .busyElsewhere, .localRinging_ReadyToAnswer: owsFailDebug("unexpected call state: \(call.individualCall.state): \(call).") } } @@ -1188,7 +1220,7 @@ import SignalMessaging } switch call.individualCall.state { - case .remoteRinging, .localRinging: + case .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting: Logger.debug("disconnect while ringing... we'll keep ringing") case .connected: call.individualCall.state = .reconnecting @@ -1326,7 +1358,7 @@ import SignalMessaging }() switch failedCall.individualCall.state { - case .answering, .localRinging: + case .answering, .localRinging_Anticipatory, .localRinging_ReadyToAnswer, .accepting: assert(failedCall.individualCall.callRecord == nil) // call failed before any call record could be created, make one now. handleMissedCall(failedCall) diff --git a/Signal/src/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift b/Signal/src/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift index 023ca1f053..6154f08918 100644 --- a/Signal/src/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift +++ b/Signal/src/Calls/UserInterface/Individual/CallKit/CallKitCallUIAdaptee.swift @@ -350,15 +350,24 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { AssertIsOnMainThread() - Logger.info("Received \(#function) CXAnswerCallAction") + Logger.info("Received \(#function) CXAnswerCallAction \(action.timeoutDate)") guard let call = callManager.callWithLocalId(action.callUUID) else { owsFailDebug("call as unexpectedly nil") action.fail() return } - self.callService.individualCallService.handleAcceptCall(call) - action.fulfill() + if call.individualCall.state == .localRinging_Anticipatory { + // We can't answer the call until RingRTC is ready + call.individualCall.state = .accepting + call.individualCall.deferredAnswerCompletion = { + action.fulfill() + } + } else { + owsAssertDebug(call.individualCall.state == .localRinging_ReadyToAnswer) + self.callService.individualCallService.handleAcceptCall(call) + action.fulfill() + } } public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { diff --git a/Signal/src/Calls/UserInterface/Individual/CallUIAdapter.swift b/Signal/src/Calls/UserInterface/Individual/CallUIAdapter.swift index bfbd5e611c..2face247a2 100644 --- a/Signal/src/Calls/UserInterface/Individual/CallUIAdapter.swift +++ b/Signal/src/Calls/UserInterface/Individual/CallUIAdapter.swift @@ -8,7 +8,7 @@ import SignalServiceKit import SignalMessaging import WebRTC -protocol CallUIAdaptee { +protocol CallUIAdaptee: AnyObject { var notificationPresenter: NotificationPresenter { get } var callService: CallService { get } var hasManualRinger: Bool { get } diff --git a/Signal/src/Calls/UserInterface/Individual/IndividualCallViewController.swift b/Signal/src/Calls/UserInterface/Individual/IndividualCallViewController.swift index 64395480fa..0b4411b4a6 100644 --- a/Signal/src/Calls/UserInterface/Individual/IndividualCallViewController.swift +++ b/Signal/src/Calls/UserInterface/Individual/IndividualCallViewController.swift @@ -641,7 +641,7 @@ class IndividualCallViewController: OWSViewController, CallObserver, CallAudioSe } private var isRenderingLocalVanityVideo: Bool { - return [.idle, .dialing, .remoteRinging, .localRinging].contains(call.individualCall.state) && !localVideoView.isHidden + return [.idle, .dialing, .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(call.individualCall.state) && !localVideoView.isHidden } private var previousOrigin: CGPoint! @@ -726,14 +726,14 @@ class IndividualCallViewController: OWSViewController, CallObserver, CallAudioSe return NSLocalizedString("IN_CALL_CONNECTING", comment: "Call setup status label") case .remoteRinging: return NSLocalizedString("IN_CALL_RINGING", comment: "Call setup status label") - case .localRinging: + case .localRinging_Anticipatory, .localRinging_ReadyToAnswer: switch call.individualCall.offerMediaType { case .audio: return NSLocalizedString("IN_CALL_RINGING_AUDIO", comment: "Call setup status label") case .video: return NSLocalizedString("IN_CALL_RINGING_VIDEO", comment: "Call setup status label") } - case .answering: + case .answering, .accepting: return NSLocalizedString("IN_CALL_SECURING", comment: "Call setup status label") case .connected: let callDuration = call.connectionDuration() @@ -827,7 +827,7 @@ class IndividualCallViewController: OWSViewController, CallObserver, CallAudioSe ) // Show Incoming vs. Ongoing call controls - if call.individualCall.state == .localRinging { + if [.localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(call.individualCall.state) { let isVideoOffer = call.individualCall.offerMediaType == .video incomingVideoCallControls.isHidden = !isVideoOffer incomingAudioCallControls.isHidden = isVideoOffer diff --git a/Signal/src/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift b/Signal/src/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift index 21ac723bbf..a29e6b06f2 100644 --- a/Signal/src/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift +++ b/Signal/src/Calls/UserInterface/Individual/NonCallKitCallUIAdaptee.swift @@ -53,7 +53,7 @@ class NonCallKitCallUIAdaptee: NSObject, CallUIAdaptee { // we re-present the notifiation every 3 seconds to make sure // the user sees that their phone is ringing incomingCallNotificationTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in - guard call.individualCall.state == .localRinging else { + guard call.individualCall.state == .localRinging_ReadyToAnswer else { self?.incomingCallNotificationTimer?.invalidate() self?.incomingCallNotificationTimer = nil return diff --git a/Signal/src/environment/PushRegistrationManager.swift b/Signal/src/environment/PushRegistrationManager.swift index 9e5ba9da9a..99147de44a 100644 --- a/Signal/src/environment/PushRegistrationManager.swift +++ b/Signal/src/environment/PushRegistrationManager.swift @@ -132,6 +132,7 @@ public enum PushRegistrationError: Error { if let callRelayPayload = callRelayPayload { Logger.info("Received VoIP push from the NSE: \(callRelayPayload)") owsAssertDebug(isWaitingForSignal.tryToSetFlag()) + callService.earlyRingNextIncomingCall = true } let isUnexpectedPush = AtomicBool(false)