diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt index b66b183833..a87c08b3e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt @@ -25,12 +25,30 @@ import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_IN import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_PRE_OFFER import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_INCOMING_RINGING import org.thoughtcrime.securesms.util.CallNotificationBuilder.Companion.TYPE_OUTGOING_RINGING -import org.thoughtcrime.securesms.webrtc.* +import org.thoughtcrime.securesms.webrtc.AudioManagerCommand +import org.thoughtcrime.securesms.webrtc.CallManager +import org.thoughtcrime.securesms.webrtc.CallViewModel +import org.thoughtcrime.securesms.webrtc.HangUpRtcOnPstnCallAnsweredListener +import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver +import org.thoughtcrime.securesms.webrtc.NetworkChangeReceiver +import org.thoughtcrime.securesms.webrtc.PeerConnectionException +import org.thoughtcrime.securesms.webrtc.PowerButtonReceiver +import org.thoughtcrime.securesms.webrtc.ProximityLockRelease +import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager +import org.thoughtcrime.securesms.webrtc.WiredHeadsetStateReceiver import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger +import org.thoughtcrime.securesms.webrtc.data.Event import org.thoughtcrime.securesms.webrtc.locks.LockManager -import org.webrtc.* -import org.webrtc.PeerConnection.IceConnectionState.* -import java.util.* +import org.webrtc.DataChannel +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnection.IceConnectionState.CONNECTED +import org.webrtc.PeerConnection.IceConnectionState.DISCONNECTED +import org.webrtc.PeerConnection.IceConnectionState.FAILED +import org.webrtc.RtpReceiver +import org.webrtc.SessionDescription +import java.util.UUID import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -56,6 +74,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { const val ACTION_WIRED_HEADSET_CHANGE = "WIRED_HEADSET_CHANGE" const val ACTION_SCREEN_OFF = "SCREEN_OFF" const val ACTION_CHECK_TIMEOUT = "CHECK_TIMEOUT" + const val ACTION_CHECK_RECONNECT = "CHECK_RECONNECT" + const val ACTION_CHECK_RECONNECT_TIMEOUT = "CHECK_RECONNECT_TIMEOUT" const val ACTION_IS_IN_CALL_QUERY = "IS_IN_CALL" const val ACTION_WANTS_TO_ANSWER = "WANTS_TO_ANSWER" @@ -80,7 +100,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { const val EXTRA_WANTS_TO_ANSWER = "wants_to_answer" const val INVALID_NOTIFICATION_ID = -1 - private const val TIMEOUT_SECONDS = 30L + private const val TIMEOUT_SECONDS = 90L + private const val MAX_TIMEOUTS = 3 fun cameraEnabled(context: Context, enabled: Boolean) = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_SET_MUTE_VIDEO) @@ -165,6 +186,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { @Inject lateinit var callManager: CallManager private var wantsToAnswer = false + private var currentTimeouts = 0 + private var isNetworkAvailable = true private val lockManager by lazy { LockManager(this) } private val serviceExecutor = Executors.newSingleThreadExecutor() @@ -185,6 +208,9 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(WebRtcCallActivity.ACTION_END)) lockManager.updatePhoneState(LockManager.PhoneState.IDLE) callManager.stop() + wantsToAnswer = false + currentTimeouts = 0 + isNetworkAvailable = true stopForeground(true) } @@ -239,6 +265,8 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { action == ACTION_ICE_MESSAGE -> handleRemoteIceCandidate(intent) action == ACTION_ICE_CONNECTED -> handleIceConnected(intent) action == ACTION_CHECK_TIMEOUT -> handleCheckTimeout(intent) + action == ACTION_CHECK_RECONNECT -> handleCheckReconnect(intent) + action == ACTION_CHECK_RECONNECT_TIMEOUT -> handleCheckReconnectTimeout(intent) action == ACTION_IS_IN_CALL_QUERY -> handleIsInCallQuery(intent) action == ACTION_UPDATE_AUDIO -> handleUpdateAudio(intent) } @@ -250,9 +278,10 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { super.onCreate() callManager.registerListener(this) wantsToAnswer = false + isNetworkAvailable = false registerIncomingPstnCallReceiver() registerWiredHeadsetStateReceiver() - registerWantsToAnswerReceiver() // TODO unregister + registerWantsToAnswerReceiver() getSystemService(TelephonyManager::class.java) .listen(hangupOnCallAnswered, PhoneStateListener.LISTEN_CALL_STATE) registerUncaughtExceptionHandler() @@ -322,7 +351,6 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } val callId = getCallId(intent) val recipient = getRemoteRecipient(intent) - val sentTimestamp = intent.getLongExtra(EXTRA_TIMESTAMP, -1) if (isIncomingMessageExpired(intent)) { insertMissedCall(recipient, true) @@ -330,17 +358,16 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { return } - setCallInProgressNotification(TYPE_INCOMING_PRE_OFFER, recipient) - callManager.onPreOffer(callId, recipient, sentTimestamp) - callManager.postViewModelState(CallViewModel.State.CALL_PRE_INIT) - callManager.initializeAudioForCall() - callManager.startIncomingRinger() - callManager.setAudioEnabled(true) + callManager.onPreOffer(callId, recipient) { + setCallInProgressNotification(TYPE_INCOMING_PRE_OFFER, recipient) + callManager.postViewModelState(CallViewModel.State.CALL_PRE_INIT) + callManager.initializeAudioForCall() + callManager.startIncomingRinger() + callManager.setAudioEnabled(true) + } } private fun handleIncomingRing(intent: Intent) { - if (!callManager.isPreOffer() && !callManager.isIdle()) throw IllegalStateException("Incoming ring on non-idle") - val callId = getCallId(intent) val recipient = getRemoteRecipient(intent) val preOffer = callManager.preOfferCallData @@ -352,119 +379,119 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { val offer = intent.getStringExtra(EXTRA_REMOTE_DESCRIPTION) ?: return val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, -1) - if (wantsToAnswer) { - setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) - } else { - setCallInProgressNotification(TYPE_INCOMING_RINGING, recipient) + + callManager.onIncomingRing(offer, callId, recipient, timestamp) { + if (wantsToAnswer) { + setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) + } else { + setCallInProgressNotification(TYPE_INCOMING_RINGING, recipient) + } + callManager.clearPendingIceUpdates() + callManager.postViewModelState(CallViewModel.State.CALL_RINGING) + registerPowerButtonReceiver() } - callManager.clearPendingIceUpdates() - callManager.onIncomingRing(offer, callId, recipient, timestamp) - callManager.postConnectionEvent(STATE_LOCAL_RINGING) - callManager.postViewModelState(CallViewModel.State.CALL_RINGING) - registerPowerButtonReceiver() } private fun handleOutgoingCall(intent: Intent) { - if (callManager.currentConnectionState != STATE_IDLE) throw IllegalStateException("Dialing from non-idle") + callManager.postConnectionEvent(Event.SendPreOffer) { + val recipient = getRemoteRecipient(intent) + callManager.recipient = recipient + val callId = UUID.randomUUID() + callManager.callId = callId - callManager.postConnectionEvent(STATE_DIALING) - val recipient = getRemoteRecipient(intent) - callManager.recipient = recipient - val callId = UUID.randomUUID() - callManager.callId = callId + callManager.initializeVideo(this) - callManager.initializeVideo(this) + callManager.postViewModelState(CallViewModel.State.CALL_OUTGOING) + lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) + callManager.initializeAudioForCall() + callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING) + setCallInProgressNotification(TYPE_OUTGOING_RINGING, callManager.recipient) + callManager.insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_OUTGOING) + timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + callManager.setAudioEnabled(true) - callManager.postViewModelState(CallViewModel.State.CALL_OUTGOING) - lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) - callManager.initializeAudioForCall() - callManager.startOutgoingRinger(OutgoingRinger.Type.RINGING) - setCallInProgressNotification(TYPE_OUTGOING_RINGING, callManager.recipient) - callManager.insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_OUTGOING) - timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) - callManager.setAudioEnabled(true) + val expectedState = callManager.currentConnectionState + val expectedCallId = callManager.callId - val expectedState = callManager.currentConnectionState - val expectedCallId = callManager.callId - - try { - val offerFuture = callManager.onOutgoingCall(this) - offerFuture.fail { e -> - if (isConsistentState(expectedState, expectedCallId, callManager.currentConnectionState, callManager.callId)) { - Log.e(TAG,e) - callManager.postViewModelState(CallViewModel.State.NETWORK_FAILURE) - terminate() + try { + val offerFuture = callManager.onOutgoingCall(this) + offerFuture.fail { e -> + if (isConsistentState(expectedState, expectedCallId, callManager.currentConnectionState, callManager.callId)) { + Log.e(TAG,e) + callManager.postViewModelState(CallViewModel.State.NETWORK_FAILURE) + callManager.postConnectionError() + terminate() + } } + } catch (e: Exception) { + Log.e(TAG,e) + callManager.postConnectionError() + terminate() } - } catch (e: Exception) { - Log.e(TAG,e) - terminate() } } private fun handleAnswerCall(intent: Intent) { val recipient = callManager.recipient ?: return - - if (callManager.currentConnectionState != STATE_LOCAL_RINGING) { - if (callManager.currentConnectionState == STATE_PRE_OFFER) { - // show answer state from pre-offer - setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) - } - Log.e(TAG, "Can only answer from ringing!") - return - } - val pending = callManager.pendingOffer ?: return val callId = callManager.callId ?: return val timestamp = callManager.pendingOfferTime - setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) + + if (callManager.currentConnectionState != CallState.RemoteRing) { + Log.e(TAG, "Can only answer from ringing!") + return + } intent.putExtra(EXTRA_CALL_ID, callId) intent.putExtra(EXTRA_RECIPIENT_ADDRESS, recipient.address) intent.putExtra(EXTRA_REMOTE_DESCRIPTION, pending) intent.putExtra(EXTRA_TIMESTAMP, timestamp) - callManager.silenceIncomingRinger() - callManager.postConnectionEvent(STATE_ANSWERING) - callManager.postViewModelState(CallViewModel.State.CALL_INCOMING) - if (isIncomingMessageExpired(intent)) { - insertMissedCall(recipient, true) - terminate() - return + val didHangup = callManager.postConnectionEvent(Event.TimeOut) { + insertMissedCall(recipient, true) + terminate() + } + if (didHangup) { + return + } } - timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + callManager.postConnectionEvent(Event.SendAnswer) { + setCallInProgressNotification(TYPE_INCOMING_CONNECTING, recipient) - callManager.initializeAudioForCall() - callManager.initializeVideo(this) + callManager.silenceIncomingRinger() + callManager.postViewModelState(CallViewModel.State.CALL_INCOMING) - val expectedState = callManager.currentConnectionState - val expectedCallId = callManager.callId + timeoutExecutor.schedule(TimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) - try { - val answerFuture = callManager.onIncomingCall(this) - answerFuture.fail { e -> - if (isConsistentState(expectedState,expectedCallId, callManager.currentConnectionState, callManager.callId)) { - Log.e(TAG, e) - insertMissedCall(recipient, true) - terminate() + callManager.initializeAudioForCall() + callManager.initializeVideo(this) + + val expectedState = callManager.currentConnectionState + val expectedCallId = callManager.callId + + try { + val answerFuture = callManager.onIncomingCall(this) + answerFuture.fail { e -> + if (isConsistentState(expectedState,expectedCallId, callManager.currentConnectionState, callManager.callId)) { + Log.e(TAG, e) + insertMissedCall(recipient, true) + callManager.postConnectionError() + terminate() + } } + lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) + callManager.setAudioEnabled(true) + } catch (e: Exception) { + Log.e(TAG,e) + callManager.postConnectionError() + terminate() } - lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING) - callManager.setAudioEnabled(true) - } catch (e: Exception) { - Log.e(TAG,e) - terminate() } } private fun handleDenyCall(intent: Intent) { - if (callManager.currentConnectionState !in CallState.CAN_DECLINE_STATES) { - Log.e(TAG,"Can only deny from ringing!") - return - } - callManager.handleDenyCall() terminate() } @@ -509,7 +536,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private fun handleResponseMessage(intent: Intent) { try { val recipient = getRemoteRecipient(intent) - if (callManager.isCurrentUser(recipient) && callManager.currentConnectionState == STATE_LOCAL_RINGING) { + if (callManager.isCurrentUser(recipient) && callManager.currentConnectionState in CallState.OUTGOING_STATES) { handleLocalHangup(intent) return } @@ -542,22 +569,20 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { private fun handleIceConnected(intent: Intent) { val recipient = callManager.recipient ?: return - if (callManager.currentConnectionState in arrayOf(STATE_ANSWERING)) { - callManager.postConnectionEvent(STATE_CONNECTED) + val connected = callManager.postConnectionEvent(Event.Connect) { callManager.postViewModelState(CallViewModel.State.CALL_CONNECTED) - } else { - Log.w(TAG, "Got ice connected out of state") + setCallInProgressNotification(TYPE_ESTABLISHED, recipient) + callManager.startCommunication(lockManager) + } + if (!connected) { + terminate() } - - setCallInProgressNotification(TYPE_ESTABLISHED, recipient) - - callManager.startCommunication(lockManager) } private fun handleIsInCallQuery(intent: Intent) { val listener = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER) ?: return val currentState = callManager.currentConnectionState - val isInCall = if (currentState in CONNECTED_STATES || currentState in PENDING_CONNECTION_STATES) 1 else 0 + val isInCall = if (currentState in arrayOf(*CallState.PENDING_CONNECTION_STATES, CallState.Connected)) 1 else 0 listener.send(isInCall, bundleOf()) } @@ -569,11 +594,32 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } } + private fun handleCheckReconnect(intent: Intent) { + val callId = callManager.callId ?: return + val numTimeouts = ++currentTimeouts + + if (callId == getCallId(intent) && isNetworkAvailable && numTimeouts <= 5) { + callManager.networkReestablished() + } + } + + private fun handleCheckReconnectTimeout(intent: Intent) { + val callId = callManager.callId ?: return + val callState = callManager.currentConnectionState + + if (callId == getCallId(intent) && (callState !in arrayOf(CallState.Connected, CallState.Connecting))) { + Log.w(TAG, "Timing out reconnect: $callId") + handleLocalHangup(intent) + } + } + + + private fun handleCheckTimeout(intent: Intent) { val callId = callManager.callId ?: return val callState = callManager.currentConnectionState - if (callId == getCallId(intent) && (callState !in arrayOf(STATE_CONNECTED) || callManager.iceState == CHECKING)) { + if (callId == getCallId(intent) && (callState !in arrayOf(CallState.Connected, CallState.Connecting))) { Log.w(TAG, "Timing out call: $callId") handleLocalHangup(intent) } @@ -622,9 +668,7 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { callReceiver?.let { receiver -> unregisterReceiver(receiver) } - networkChangedReceiver?.let { receiver -> - receiver.unregister(this) - } + networkChangedReceiver?.unregister(this) wantsToAnswerReceiver?.let { receiver -> LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) } @@ -632,12 +676,33 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { callReceiver = null uncaughtExceptionHandlerManager?.unregister() wantsToAnswer = false + currentTimeouts = 0 + isNetworkAvailable = true super.onDestroy() } fun networkChange(networkAvailable: Boolean) { - if (networkAvailable && !callManager.isReestablishing && callManager.currentConnectionState in arrayOf(STATE_CONNECTED)) { - callManager.networkReestablished() + isNetworkAvailable = networkAvailable + if (networkAvailable && !callManager.isReestablishing && callManager.currentConnectionState == CallState.Connected) { + Log.d("Loki", "Should reconnected") + } + } + + private class CheckReconnectedRunnable(private val callId: UUID, private val context: Context): Runnable { + override fun run() { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_CHECK_RECONNECT) + .putExtra(EXTRA_CALL_ID, callId) + context.startService(intent) + } + } + + private class ReconnectTimeoutRunnable(private val callId: UUID, private val context: Context): Runnable { + override fun run() { + val intent = Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_CHECK_RECONNECT_TIMEOUT) + .putExtra(EXTRA_CALL_ID, callId) + context.startService(intent) } } @@ -651,16 +716,16 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } private abstract class FailureListener( - expectedState: CallManager.CallState, + expectedState: CallState, expectedCallId: UUID?, - getState: () -> Pair): StateAwareListener(expectedState, expectedCallId, getState) { + getState: () -> Pair): StateAwareListener(expectedState, expectedCallId, getState) { override fun onSuccessContinue(result: V) {} } private abstract class SuccessOnlyListener( - expectedState: CallManager.CallState, + expectedState: CallState, expectedCallId: UUID?, - getState: () -> Pair): StateAwareListener(expectedState, expectedCallId, getState) { + getState: () -> Pair): StateAwareListener(expectedState, expectedCallId, getState) { override fun onFailureContinue(throwable: Throwable?) { Log.e(TAG, throwable) throw AssertionError(throwable) @@ -668,9 +733,9 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } private abstract class StateAwareListener( - private val expectedState: CallManager.CallState, + private val expectedState: CallState, private val expectedCallId: UUID?, - private val getState: ()->Pair): FutureTaskListener { + private val getState: ()->Pair): FutureTaskListener { companion object { private val TAG = Log.tag(StateAwareListener::class.java) @@ -706,9 +771,9 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { } private fun isConsistentState( - expectedState: CallManager.CallState, + expectedState: CallState, expectedCallId: UUID?, - currentState: CallManager.CallState, + currentState: CallState, currentCallId: UUID? ): Boolean { return expectedState == currentState && expectedCallId == currentCallId @@ -717,16 +782,22 @@ class WebRtcCallService: Service(), CallManager.WebRtcListener { override fun onSignalingChange(p0: PeerConnection.SignalingState?) {} override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { - if (newState in arrayOf(CONNECTED, COMPLETED)) { + if (newState == CONNECTED) { val intent = Intent(this, WebRtcCallService::class.java) .setAction(ACTION_ICE_CONNECTED) startService(intent) } else if (newState == FAILED) { - val intent = Intent(this, WebRtcCallService::class.java) - .setAction(ACTION_LOCAL_HANGUP) - .putExtra(EXTRA_CALL_ID, callManager.callId) - + val intent = hangupIntent(this) startService(intent) + } else if (newState == DISCONNECTED) { + callManager.callId?.let { callId -> + callManager.postViewModelState(CallViewModel.State.CALL_RECONNECTING) + timeoutExecutor.schedule(CheckReconnectedRunnable(callId, this), 5, TimeUnit.SECONDS) + timeoutExecutor.schedule(ReconnectTimeoutRunnable(callId, this), TIMEOUT_SECONDS, TimeUnit.SECONDS) + } ?: run { + val intent = hangupIntent(this) + startService(intent) + } } Log.d(TAG, "onIceConnectionChange: $newState") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 9aac4da7fb..5b27cd79a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -24,13 +24,13 @@ import org.session.libsignal.protos.SignalServiceProtos.CallMessage.Type.ICE_CAN import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled -import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.CallStateUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoEnabled import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice +import org.thoughtcrime.securesms.webrtc.data.Event import org.thoughtcrime.securesms.webrtc.data.StateProcessor import org.thoughtcrime.securesms.webrtc.locks.LockManager import org.thoughtcrime.securesms.webrtc.video.CameraEventListener @@ -163,8 +163,12 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va signalAudioManager.handleCommand(AudioManagerCommand.SilenceIncomingRinger) } - fun postConnectionEvent(newState: CallState) { - _connectionEvents.value = CallStateUpdate(newState) + fun postConnectionEvent(transition: Event, onSuccess: ()->Unit): Boolean { + return stateProcessor.processEvent(transition, onSuccess) + } + + fun postConnectionError(): Boolean { + return stateProcessor.processEvent(Event.Error) } fun postViewModelState(newState: CallViewModel.State) { @@ -221,7 +225,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va } fun setAudioEnabled(isEnabled: Boolean) { - currentConnectionState.withState(*PENDING_CONNECTION_STATES)) { + currentConnectionState.withState(*CallState.CAN_HANGUP_STATES) { peerConnection?.setAudioEnabled(isEnabled) _audioEvents.value = AudioEnabled(true) } @@ -345,51 +349,50 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va _audioDeviceEvents.value = AudioDeviceUpdate(activeDevice, devices) } - private fun CallState.withState(vararg expected: CallState, transition: () -> Unit) { - if (this in expected) transition() - else Log.w(TAG,"Tried to transition state $this but expected $expected") - } - fun stop() { - signalAudioManager.handleCommand(AudioManagerCommand.Stop(currentConnectionState in OUTGOING_STATES)) - peerConnection?.dispose() - peerConnection = null + val isOutgoing = currentConnectionState in CallState.OUTGOING_STATES + stateProcessor.processEvent(Event.Cleanup) { + signalAudioManager.handleCommand(AudioManagerCommand.Stop(isOutgoing)) + peerConnection?.dispose() + peerConnection = null - localRenderer?.release() - remoteRotationSink?.release() - remoteRenderer?.release() - eglBase?.release() + localRenderer?.release() + remoteRotationSink?.release() + remoteRenderer?.release() + eglBase?.release() - localRenderer = null - remoteRenderer = null - eglBase = null + localRenderer = null + remoteRenderer = null + eglBase = null - _connectionEvents.value = CallStateUpdate(CallState.STATE_IDLE) - localCameraState = CameraState.UNKNOWN - recipient = null - callId = null - pendingOfferTime = -1 - pendingOffer = null - callStartTime = -1 - _audioEvents.value = AudioEnabled(false) - _videoEvents.value = VideoEnabled(false) - _remoteVideoEvents.value = VideoEnabled(false) - pendingOutgoingIceUpdates.clear() - pendingIncomingIceUpdates.clear() + localCameraState = CameraState.UNKNOWN + recipient = null + callId = null + pendingOfferTime = -1 + pendingOffer = null + callStartTime = -1 + _audioEvents.value = AudioEnabled(false) + _videoEvents.value = VideoEnabled(false) + _remoteVideoEvents.value = VideoEnabled(false) + pendingOutgoingIceUpdates.clear() + pendingIncomingIceUpdates.clear() + } } override fun onCameraSwitchCompleted(newCameraState: CameraState) { localCameraState = newCameraState } - fun onPreOffer(callId: UUID, recipient: Recipient, sentTimestamp: Long) { - if (preOfferCallData != null) { - Log.d(TAG, "Received new pre-offer when we are already expecting one") + fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) { + stateProcessor.processEvent(Event.ReceivePreOffer) { + if (preOfferCallData != null) { + Log.d(TAG, "Received new pre-offer when we are already expecting one") + } + this.recipient = recipient + this.callId = callId + preOfferCallData = PreOffer(callId, recipient) + onSuccess() } - this.recipient = recipient - this.callId = callId - preOfferCallData = PreOffer(callId, recipient) - postConnectionEvent(CallState.STATE_PRE_OFFER) } fun onNewOffer(offer: String, callId: UUID, recipient: Recipient): Promise { @@ -407,15 +410,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va return MessageSender.sendNonDurably(answerMessage, recipient.address) } - fun onIncomingRing(offer: String, callId: UUID, recipient: Recipient, callTime: Long) { - if (currentConnectionState !in arrayOf(CallState.STATE_IDLE, CallState.STATE_PRE_OFFER)) return - - this.callId = callId - this.recipient = recipient - this.pendingOffer = offer - this.pendingOfferTime = callTime - initializeAudioForCall() - startIncomingRinger() + fun onIncomingRing(offer: String, callId: UUID, recipient: Recipient, callTime: Long, onSuccess: () -> Unit) { + postConnectionEvent(Event.ReceiveOffer) { + this.callId = callId + this.recipient = recipient + this.pendingOffer = offer + this.pendingOfferTime = callTime + initializeAudioForCall() + startIncomingRinger() + onSuccess() + } } fun onIncomingCall(context: Context, isAlwaysTurn: Boolean = false): Promise { @@ -472,7 +476,12 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va ?: return Promise.ofFail(NullPointerException("localRenderer is null")) val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) - val connection = PeerConnectionWrapper( + val sentOffer = stateProcessor.processEvent(Event.SendOffer) + + if (!sentOffer) { + return Promise.ofFail(Exception("Couldn't transition to sent offer state")) + } else { + val connection = PeerConnectionWrapper( context, factory, this, @@ -480,23 +489,24 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va this, base, isAlwaysTurn - ) + ) - peerConnection = connection - localCameraState = connection.getCameraState() - val dataChannel = connection.createDataChannel(DATA_CHANNEL_NAME) - dataChannel.registerObserver(this) - this.dataChannel = dataChannel - val offer = connection.createOffer(MediaConstraints()) - connection.setLocalDescription(offer) + peerConnection = connection + localCameraState = connection.getCameraState() + val dataChannel = connection.createDataChannel(DATA_CHANNEL_NAME) + dataChannel.registerObserver(this) + this.dataChannel = dataChannel + val offer = connection.createOffer(MediaConstraints()) + connection.setLocalDescription(offer) - return MessageSender.sendNonDurably(CallMessage.preOffer( + return MessageSender.sendNonDurably(CallMessage.preOffer( callId - ), recipient.address).bind { - MessageSender.sendNonDurably(CallMessage.offer( + ), recipient.address).bind { + MessageSender.sendNonDurably(CallMessage.offer( offer.description, callId - ), recipient.address) + ), recipient.address) + } } } @@ -507,9 +517,11 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val callId = callId ?: return val recipient = recipient ?: return val userAddress = storage.getUserPublicKey() ?: return - MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) - MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) - insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) + stateProcessor.processEvent(Event.DeclineCall) { + MessageSender.sendNonDurably(CallMessage.endCall(callId), Address.fromSerialized(userAddress)) + MessageSender.sendNonDurably(CallMessage.endCall(callId), recipient.address) + insertCallMessage(recipient.address.serialize(), CallMessageType.CALL_MISSED) + } } fun handleLocalHangup(intentRecipient: Recipient?) { @@ -520,6 +532,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val sendHangup = intentRecipient == null || (intentRecipient == recipient && recipient.address.serialize() != currentUserPublicKey) postViewModelState(CallViewModel.State.CALL_DISCONNECTED) + stateProcessor.processEvent(Event.Hangup) if (sendHangup) { dataChannel?.let { channel -> val buffer = DataChannel.Buffer(ByteBuffer.wrap(HANGUP_JSON.toString().encodeToByteArray()), false) @@ -535,8 +548,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va fun handleRemoteHangup() { when (currentConnectionState) { - CallState.STATE_DIALING, - CallState.STATE_REMOTE_RINGING -> postViewModelState(CallViewModel.State.RECIPIENT_UNAVAILABLE) + CallState.LocalRing, + CallState.RemoteRing -> postViewModelState(CallViewModel.State.RECIPIENT_UNAVAILABLE) else -> postViewModelState(CallViewModel.State.CALL_DISCONNECTED) } } @@ -556,7 +569,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va channel.send(buffer) } - if (currentConnectionState == CallState.STATE_CONNECTED) { + if (currentConnectionState == CallState.Connected) { if (connection.isVideoEnabled()) lockManager.updatePhoneState(LockManager.PhoneState.IN_VIDEO) else lockManager.updatePhoneState(LockManager.PhoneState.IN_CALL) } @@ -585,9 +598,9 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va } fun handleWiredHeadsetChanged(present: Boolean) { - if (currentConnectionState in arrayOf(CallState.STATE_CONNECTED, - CallState.STATE_DIALING, - CallState.STATE_REMOTE_RINGING)) { + if (currentConnectionState in arrayOf(CallState.Connected, + CallState.LocalRing, + CallState.RemoteRing)) { if (present && signalAudioManager.isSpeakerphoneOn()) { signalAudioManager.handleCommand(AudioManagerCommand.SetUserDevice(AudioDevice.WIRED_HEADSET)) } else if (!present && !signalAudioManager.isSpeakerphoneOn() && !signalAudioManager.isBluetoothScoOn() && localCameraState.enabled) { @@ -597,24 +610,26 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va } fun handleScreenOffChange() { - if (currentConnectionState in arrayOf(CallState.STATE_ANSWERING, CallState.STATE_LOCAL_RINGING)) { + if (currentConnectionState in arrayOf(CallState.Connecting, CallState.LocalRing)) { signalAudioManager.handleCommand(AudioManagerCommand.SilenceIncomingRinger) } } fun handleResponseMessage(recipient: Recipient, callId: UUID, answer: SessionDescription) { - if (currentConnectionState !in arrayOf(CallState.STATE_DIALING, CallState.STATE_CONNECTED) || recipient != this.recipient || callId != this.callId) { + if (recipient != this.recipient || callId != this.callId) { Log.w(TAG,"Got answer for recipient and call ID we're not currently dialing") return } - val connection = peerConnection ?: throw AssertionError("assert") + stateProcessor.processEvent(Event.ReceiveAnswer) { + val connection = peerConnection ?: throw AssertionError("assert") - connection.setRemoteDescription(answer) - while (pendingIncomingIceUpdates.isNotEmpty()) { - connection.addIceCandidate(pendingIncomingIceUpdates.pop()) + connection.setRemoteDescription(answer) + while (pendingIncomingIceUpdates.isNotEmpty()) { + connection.addIceCandidate(pendingIncomingIceUpdates.pop()) + } + queueOutgoingIce(callId, recipient) } - queueOutgoingIce(callId, recipient) } fun handleRemoteIceCandidate(iceCandidates: List, callId: UUID) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt index 59d85700a8..4f27e5d1ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt @@ -21,6 +21,7 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V CALL_RINGING, CALL_BUSY, CALL_DISCONNECTED, + CALL_RECONNECTING, NETWORK_FAILURE, RECIPIENT_UNAVAILABLE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt index f82bab2217..94ccaa52fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/data/StateMachine.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.webrtc.data +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.webrtc.data.State.Companion.CAN_DECLINE_STATES import org.thoughtcrime.securesms.webrtc.data.State.Companion.CAN_HANGUP_STATES sealed class State { @@ -13,6 +15,10 @@ sealed class State { object Reconnecting : State() object Disconnected : State() companion object { + + val ALL_STATES = arrayOf(Idle, RemotePreOffer, RemoteRing, LocalPreOffer, LocalRing, + Connecting, Connected, Reconnecting, Disconnected) + val CAN_DECLINE_STATES = arrayOf(RemotePreOffer, RemoteRing) val PENDING_CONNECTION_STATES = arrayOf( LocalPreOffer, @@ -26,10 +32,17 @@ sealed class State { LocalRing, ) val CAN_HANGUP_STATES = - arrayOf(LocalPreOffer, LocalRing, Connecting, Connected, Reconnecting) + arrayOf(RemotePreOffer, RemoteRing, LocalPreOffer, LocalRing, Connecting, Connected, Reconnecting) val CAN_RECEIVE_ICE_STATES = arrayOf(RemoteRing, LocalRing, Connecting, Connected, Reconnecting) } + + fun withState(vararg expectedState: State, body: ()->Unit) { + if (this in expectedState) { + body() + } + } + } sealed class Event(vararg val expectedStates: State, val outputState: State) { @@ -47,7 +60,8 @@ sealed class Event(vararg val expectedStates: State, val outputState: State) { object NetworkReconnect : Event(State.Reconnecting, outputState = State.Connecting) object TimeOut : Event(State.Connecting, State.LocalRing, State.RemoteRing, outputState = State.Disconnected) - + object Error : Event(*State.ALL_STATES, outputState = State.Disconnected) + object DeclineCall : Event(*CAN_DECLINE_STATES, outputState = State.Disconnected) object Hangup : Event(*CAN_HANGUP_STATES, outputState = State.Disconnected) object Cleanup : Event(State.Disconnected, outputState = State.Idle) } @@ -62,6 +76,7 @@ open class StateProcessor(initialState: State) { sideEffect() return true } + Log.e("Loki-Call", "error transitioning from $currentState with ${event::class.simpleName}") return false } } \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/calls/CallStateMachineTests.kt b/app/src/test/java/org/thoughtcrime/securesms/calls/CallStateMachineTests.kt index 01b1ff065c..51ce2fa403 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/calls/CallStateMachineTests.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/calls/CallStateMachineTests.kt @@ -1,8 +1,13 @@ package org.thoughtcrime.securesms.calls +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito.any +import org.mockito.Mockito.mockStatic +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.webrtc.data.Event import org.thoughtcrime.securesms.webrtc.data.State @@ -10,9 +15,19 @@ class CallStateMachineTests { private lateinit var stateProcessor: TestStateProcessor + lateinit var mock: MockedStatic + @Before fun setup() { stateProcessor = TestStateProcessor(State.Idle) + mock = mockStatic(Log::class.java).apply { + `when` { Log.e(any(), any(), any()) }.then { /* do nothing */ } + } + } + + @After + fun teardown() { + mock.close() } @Test @@ -119,6 +134,10 @@ class CallStateMachineTests { Event.ReceiveOffer, Event.SendAnswer, Event.IceFailed, + Event.Cleanup, + Event.ReceivePreOffer, + Event.ReceiveOffer, + Event.DeclineCall, Event.Cleanup ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapterTest.java b/app/src/test/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapterTest.java index 059cadf0c6..47977e2867 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapterTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapterTest.java @@ -1,20 +1,22 @@ package org.thoughtcrime.securesms.database; -import android.content.Context; -import android.database.Cursor; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; -import android.view.View; -import android.view.ViewGroup; - -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import android.content.Context; +import android.database.Cursor; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import org.junit.Before; +import org.junit.Test; + public class CursorRecyclerViewAdapterTest { private CursorRecyclerViewAdapter adapter; private Context context;