From 78907cbcc12b178aa58523766765af28572b6e50 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 29 Aug 2022 16:47:04 +0100 Subject: [PATCH] feat(WEBRTC-2102): implement reconnect functionality with attach flow (#205) * chore: fix CauseCode values * feat: implement reconnect() on failure * fix: set call within login logic * feat: convert sessionId to sessid * feat: implement reattach flow and state changes * fix: implement correct comment * feat: strip previous sessionid logic. * fix: remove old listener method * fix: add check to see if testing before returning call --- .../telnyx/webrtc/sdk/manager/UserManager.kt | 2 +- .../main/java/com/telnyx/webrtc/sdk/Call.kt | 85 +++++++++++++++++-- .../com/telnyx/webrtc/sdk/TelnyxClient.kt | 71 ++++++++++------ .../com/telnyx/webrtc/sdk/model/CallState.kt | 1 + .../com/telnyx/webrtc/sdk/model/CauseCode.kt | 10 +-- .../telnyx/webrtc/sdk/model/SocketMethod.kt | 1 + .../com/telnyx/webrtc/sdk/socket/TxSocket.kt | 12 ++- .../webrtc/sdk/socket/TxSocketListener.kt | 20 +++-- .../sdk/verto/receive/ReceivedResult.kt | 6 +- .../webrtc/sdk/verto/send/ParamRequest.kt | 11 +-- .../java/com/telnyx/webrtc/sdk/CallTest.kt | 25 ++---- .../com/telnyx/webrtc/sdk/TelnyxClientTest.kt | 12 --- 12 files changed, 167 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/telnyx/webrtc/sdk/manager/UserManager.kt b/app/src/main/java/com/telnyx/webrtc/sdk/manager/UserManager.kt index c0ec34d4..4e6dbfd4 100644 --- a/app/src/main/java/com/telnyx/webrtc/sdk/manager/UserManager.kt +++ b/app/src/main/java/com/telnyx/webrtc/sdk/manager/UserManager.kt @@ -64,7 +64,7 @@ class UserManager @Inject constructor(private val sharedPreferences: SharedPrefe } set(callerNumber) = sharedPreferences.edit().putString(KEY_USER_CALLER_ID_NUMBER, callerNumber).apply() - var sessionId: String + var sessid: String get() { val temp = sharedPreferences.getString(KEY_USER_SESSION_ID, "") return temp ?: "" diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/Call.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/Call.kt index 0f58fb05..a38f5b71 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/Call.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/Call.kt @@ -19,7 +19,6 @@ import com.telnyx.webrtc.sdk.socket.TxSocketListener import com.telnyx.webrtc.sdk.utilities.encodeBase64 import com.telnyx.webrtc.sdk.verto.receive.* import com.telnyx.webrtc.sdk.verto.send.* -import io.ktor.util.* import org.webrtc.IceCandidate import org.webrtc.SessionDescription import timber.log.Timber @@ -120,7 +119,7 @@ class Call( id = uuid, method = SocketMethod.INVITE.methodName, params = CallParams( - sessionId = sessionId, + sessid = sessionId, sdp = peerConnection?.getLocalDescription()?.description.toString(), dialogParams = CallDialogParams( callerIdName = callerName, @@ -158,7 +157,7 @@ class Call( val answerBodyMessage = SendingMessageBody( uuid, SocketMethod.ANSWER.methodName, CallParams( - sessionId = sessionId, + sessid = sessionId, sdp = sessionDescriptionString, dialogParams = CallDialogParams( callId = callId, @@ -173,6 +172,37 @@ class Call( } } + /** + * Accepts an attach invitation + * Functions the same as the acceptCall but changes the attach param to true + * @param callId, the callId provided with the invitation + * @param destinationNumber, the number or SIP name that will receive the invitation + * @see [Call] + */ + private fun acceptReattachCall(callId: UUID, destinationNumber: String) { + val uuid: String = UUID.randomUUID().toString() + val sessionDescriptionString = + peerConnection?.getLocalDescription()?.description + if (sessionDescriptionString == null) { + callStateLiveData.postValue(CallState.ERROR) + } else { + val answerBodyMessage = SendingMessageBody( + uuid, SocketMethod.ATTACH.methodName, + CallParams( + sessid = sessionId, + sdp = sessionDescriptionString, + dialogParams = CallDialogParams( + attach = true, + callId = callId, + destinationNumber = destinationNumber + ) + ) + ) + socket.send(answerBodyMessage) + callStateLiveData.postValue(CallState.ACTIVE) + } + } + /** * Ends an ongoing call with a provided callID, the unique UUID belonging to each call * @param callId, the callId provided with the invitation @@ -256,7 +286,7 @@ class Call( id = uuid, method = SocketMethod.MODIFY.methodName, params = ModifyParams( - sessionId = sessionId, + sessid = sessionId, action = holdAction, dialogParams = CallDialogParams( callId = callId, @@ -279,7 +309,7 @@ class Call( id = uuid, method = SocketMethod.INFO.methodName, params = InfoParams( - sessionId = sessionId, + sessid = sessionId, dtmf = tone, dialogParams = CallDialogParams( callId = callId, @@ -526,15 +556,52 @@ class Call( ) } - override fun onClientReady(jsonObject: JsonObject) { - // NOOP + override fun onAttachReceived(jsonObject: JsonObject) { + Timber.d("[%s] :: onAttachReceived [%s]", this@Call.javaClass.simpleName, jsonObject) + val params = jsonObject.getAsJsonObject("params") + val callId = UUID.fromString(params.get("callID").asString) + val remoteSdp = params.get("sdp").asString + val callerNumber = params.get("caller_id_number").asString + + peerConnection = Peer( + context, client, providedTurn, providedStun, + object : PeerConnectionObserver() { + override fun onIceCandidate(p0: IceCandidate?) { + super.onIceCandidate(p0) + peerConnection?.addIceCandidate(p0) + } + } + ) + + peerConnection?.startLocalAudioCapture() + + peerConnection?.onRemoteSessionReceived( + SessionDescription( + SessionDescription.Type.OFFER, + remoteSdp + ) + ) + + peerConnection?.answer(AppSdpObserver()) + + val iceCandidateTimer = Timer() + iceCandidateTimer.schedule( + timerTask { + acceptReattachCall(callId, callerNumber) + }, + ICE_CANDIDATE_DELAY + ) } - override fun onSessionIdReceived(jsonObject: JsonObject) { + override fun setCallRecovering() { + callStateLiveData.postValue(CallState.RECOVERING) + } + + override fun onClientReady(jsonObject: JsonObject) { // NOOP } - override fun onGatewayStateReceived(gatewayState: String, sessionId: String?) { + override fun onGatewayStateReceived(gatewayState: String, receivedSessionId: String?) { // NOOP } diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/TelnyxClient.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/TelnyxClient.kt index e4a98bdc..b7863d67 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/TelnyxClient.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/TelnyxClient.kt @@ -64,7 +64,7 @@ class TelnyxClient( // MediaPlayer for ringtone / ringbacktone private var mediaPlayer: MediaPlayer? = null - private var sessionId: String? = null + var sessid: String // sessid used to recover calls when reconnecting val socketResponseLiveData = MutableLiveData>() val wsMessagesResponseLiveDate = MutableLiveData() @@ -82,11 +82,22 @@ class TelnyxClient( * @return [Call] */ private fun buildCall(): Call? { - sessionId?.let { - return Call(context, this, socket, sessionId!!, audioManager!!, providedTurn!!, providedStun!!) + if (!BuildConfig.IS_TESTING.get()) { + sessid.let { + return Call( + context, + this, + socket, + sessid, + audioManager!!, + providedTurn!!, + providedStun!! + ) + } + } else { + // We are testing, and will instead return a mocked call. + return null } - socketResponseLiveData.postValue(SocketResponse.error("Session ID is not set, failed to build call")) - return null } /** @@ -153,10 +164,10 @@ class TelnyxClient( credentialSessionConfig?.let { credentialLogin(it) } ?: tokenLogin(tokenSessionConfig!!) - } - // Change an ongoing call's socket to the new socket. - call?.let { call?.socket = socket } + // Change an ongoing call's socket to the new socket. + call?.let { call?.socket = socket } + } } init { @@ -164,6 +175,9 @@ class TelnyxClient( Bugsnag.start(context) } + // Generate random UUID for sessid param, convert it to string and set globally + sessid = UUID.randomUUID().toString() + socket = TxSocket( host_address = Config.TELNYX_PROD_HOST_ADDRESS, port = Config.TELNYX_PORT @@ -319,7 +333,8 @@ class TelnyxClient( login = user, passwd = password, userVariables = notificationJsonObject, - loginParams = arrayListOf() + loginParams = arrayListOf(), + sessid = sessid ) ) @@ -360,7 +375,8 @@ class TelnyxClient( login = null, passwd = null, userVariables = notificationJsonObject, - loginParams = arrayListOf() + loginParams = arrayListOf(), + sessid = sessid ) ) socket.send(loginMessage) @@ -508,7 +524,7 @@ class TelnyxClient( this@TelnyxClient.javaClass.simpleName, receivedLoginSessionId ) - sessionId = receivedLoginSessionId + sessid = receivedLoginSessionId socketResponseLiveData.postValue( SocketResponse.messageReceived( ReceivedMessageBody( @@ -576,12 +592,6 @@ class TelnyxClient( } } - override fun onSessionIdReceived(jsonObject: JsonObject) { - val result = jsonObject.get("result") - val sessId = result.asJsonObject.get("sessid").asString - sessionId = sessId - } - override fun onGatewayStateReceived(gatewayState: String, receivedSessionId: String?) { when (gatewayState) { GatewayState.REGED.state -> { @@ -591,12 +601,8 @@ class TelnyxClient( resetGatewayCounters() onLoginSuccessful(it) } ?: kotlin.run { - if (sessionId != null) { - resetGatewayCounters() - onLoginSuccessful(sessionId!!) - } else { - socketResponseLiveData.postValue(SocketResponse.error("No session ID received. Please try again")) - } + resetGatewayCounters() + onLoginSuccessful(sessid) } } GatewayState.NOREG.state -> { @@ -610,7 +616,10 @@ class TelnyxClient( GatewayState.FAIL_WAIT.state -> { if (autoReconnectLogin && connectRetryCounter < RETRY_CONNECT_TIME) { connectRetryCounter++ - Timber.d("[%s] :: Attempting reconnection :: attempt $connectRetryCounter / $RETRY_CONNECT_TIME", this@TelnyxClient.javaClass.simpleName) + Timber.d( + "[%s] :: Attempting reconnection :: attempt $connectRetryCounter / $RETRY_CONNECT_TIME", + this@TelnyxClient.javaClass.simpleName + ) runBlocking { reconnectToSocket() } } else { invalidateGatewayResponseTimer() @@ -680,7 +689,11 @@ class TelnyxClient( } override fun onRingingReceived(jsonObject: JsonObject) { - Timber.d("[%s] :: onRingingReceived [%s]", this@TelnyxClient.javaClass.simpleName, jsonObject) + Timber.d( + "[%s] :: onRingingReceived [%s]", + this@TelnyxClient.javaClass.simpleName, + jsonObject + ) call?.onRingingReceived(jsonObject) } @@ -688,6 +701,14 @@ class TelnyxClient( call?.onIceCandidateReceived(iceCandidate) } + override fun onAttachReceived(jsonObject: JsonObject) { + call?.onAttachReceived(jsonObject) + } + + override fun setCallRecovering() { + call?.setCallRecovering() + } + internal fun onRemoteSessionErrorReceived(errorMessage: String?) { stopMediaPlayer() socketResponseLiveData.postValue(errorMessage?.let { SocketResponse.error(it) }) diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CallState.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CallState.kt index 137a5e32..bc8cc4a2 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CallState.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CallState.kt @@ -19,6 +19,7 @@ package com.telnyx.webrtc.sdk.model enum class CallState { NEW, CONNECTING, + RECOVERING, RINGING, ACTIVE, HELD, diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CauseCode.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CauseCode.kt index 91369092..bda463bb 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CauseCode.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/CauseCode.kt @@ -9,7 +9,7 @@ package com.telnyx.webrtc.sdk.model * Enum class to represent the different Cause Codes that are received when an invitation is refused * with a given [code] * - * @param code is the numerical representation of the cause, eg. 1 -> USER_BUSY + * @param code is the numerical representation of the cause, eg. 17 -> USER_BUSY * * @property USER_BUSY This cause is used to indicate that the called party is unable to accept another call because the user busy condition has been encountered. * @property NORMAL_CLEARING This cause indicates that the call is being cleared because one of the users involved in the call has requested that the call be cleared. Under normal situations, the source of this cause is not the network. @@ -17,8 +17,8 @@ package com.telnyx.webrtc.sdk.model * @property ORIGINATOR_CANCEL This cause indicates that the user initiating the call cancelled it before it was answered */ enum class CauseCode(var code: Int) { - USER_BUSY(1), - NORMAL_CLEARING(2), - INVALID_GATEWAY(3), - ORIGINATOR_CANCEL(4) + USER_BUSY(17), + NORMAL_CLEARING(16), + INVALID_GATEWAY(608), + ORIGINATOR_CANCEL(487) } diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/SocketMethod.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/SocketMethod.kt index d976710c..e994fb5d 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/SocketMethod.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/model/SocketMethod.kt @@ -21,6 +21,7 @@ package com.telnyx.webrtc.sdk.model */ enum class SocketMethod(var methodName: String) { ANSWER("telnyx_rtc.answer"), + ATTACH("telnyx_rtc.attach"), INVITE("telnyx_rtc.invite"), BYE("telnyx_rtc.bye"), MODIFY("telnyx_rtc.modify"), diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocket.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocket.kt index 0ae72e86..a3c39066 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocket.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocket.kt @@ -61,6 +61,7 @@ class TxSocket( ) = launch { client = OkHttpClient.Builder() .addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .retryOnConnectionFailure(true) .connectTimeout(25, TimeUnit.SECONDS) .readTimeout(25, TimeUnit.SECONDS) .writeTimeout(25, TimeUnit.SECONDS) @@ -122,8 +123,6 @@ class TxSocket( val message = result.get("message").asString if (message == "logged in" && isLoggedIn) { listener.onClientReady(jsonObject) - } else { - listener.onSessionIdReceived(jsonObject) } } } @@ -144,6 +143,9 @@ class TxSocket( CLIENT_READY.methodName -> { listener.onClientReady(jsonObject) } + ATTACH.methodName -> { + listener.onAttachReceived(jsonObject) + } INVITE.methodName -> { listener.onOfferReceived(jsonObject) } @@ -203,7 +205,11 @@ class TxSocket( } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Timber.tag("TxSocket").i("Socket is closed: $response $t") + Timber.tag("TxSocket") + .i("Socket is closed: $response $t :: Will attempt to reconnect") + if (ongoingCall) { + listener.call?.setCallRecovering() + } } } ) diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocketListener.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocketListener.kt index 55d274f6..89de2fb0 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocketListener.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/socket/TxSocketListener.kt @@ -20,13 +20,6 @@ interface TxSocketListener { */ fun onClientReady(jsonObject: JsonObject) - /** - * Fires once we have received a sessionID from the Telnyx web socket. - * @param jsonObject, the socket response in a jsonObject format - * @see [TxSocket] - */ - fun onSessionIdReceived(jsonObject: JsonObject) - /** * Fires once a Gateway state has been received. These are used to find a verified registration * @param gatewayState, the string representation of the gateway state received from the socket connection @@ -89,4 +82,17 @@ interface TxSocketListener { * @see [IceCandidate] */ fun onIceCandidateReceived(iceCandidate: IceCandidate) + + /** + * Fires once a connection has been reestablished during an ongoing call and a session + * is being reattached + * @param jsonObject, the socket response in a jsonObject format + */ + fun onAttachReceived(jsonObject: JsonObject) + + /** + * Fires when network has dropped during an ongoing call. Signifies that the SDK will attempt + * to recover once network has returned + */ + fun setCallRecovering() } diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/receive/ReceivedResult.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/receive/ReceivedResult.kt index 8f2de49a..115e7fa4 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/receive/ReceivedResult.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/receive/ReceivedResult.kt @@ -46,7 +46,7 @@ data class AnswerResponse( * @param sdp the Session Description Protocol that is received as a part of the answer to the call. * @param callerIdName the name of the person who sent the invitation * @param callerIdNumber the number of the person who sent the invitation - * @param sessionId the Telnyx Session ID on the socket connection. + * @param sessid the Telnyx Session ID on the socket connection. */ @Parcelize data class InviteResponse( @@ -58,6 +58,6 @@ data class InviteResponse( val callerIdName: String, @SerializedName("callerIdNumber") val callerIdNumber: String, - @SerializedName("sessionId") - val sessionId: String + @SerializedName("sessid") + val sessid: String ) : ReceivedResult(), Parcelable diff --git a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/send/ParamRequest.kt b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/send/ParamRequest.kt index f34981a4..083871c1 100644 --- a/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/send/ParamRequest.kt +++ b/telnyx_rtc/src/main/java/com/telnyx/webrtc/sdk/verto/send/ParamRequest.kt @@ -15,11 +15,12 @@ data class LoginParam( val login: String?, val passwd: String?, val userVariables: JsonObject, - val loginParams: ArrayList? + val loginParams: ArrayList?, + val sessid: String ) : ParamRequest() data class CallParams( - val sessionId: String, + val sessid: String, val sdp: String, @SerializedName("User-Agent") val userAgent: String = "Android-" + BuildConfig.SDK_VERSION.toString(), @@ -27,20 +28,20 @@ data class CallParams( ) : ParamRequest() data class ByeParams( - val sessionId: String, + val sessid: String, val causeCode: Int, val cause: String, val dialogParams: ByeDialogParams ) : ParamRequest() data class ModifyParams( - val sessionId: String, + val sessid: String, val action: String, val dialogParams: CallDialogParams ) : ParamRequest() data class InfoParams( - val sessionId: String, + val sessid: String, val dtmf: String, val dialogParams: CallDialogParams ) : ParamRequest() diff --git a/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/CallTest.kt b/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/CallTest.kt index 14c0403c..60a59b43 100644 --- a/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/CallTest.kt +++ b/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/CallTest.kt @@ -40,12 +40,16 @@ class CallTest : BaseTest() { @MockK private lateinit var mockContext: Context + @Spy lateinit var client: TelnyxClient + @Spy lateinit var socket: TxSocket + @Spy lateinit var call: Call + @MockK lateinit var audioManager: AudioManager @@ -153,7 +157,8 @@ class CallTest : BaseTest() { ) call.dtmf(UUID.randomUUID(), "2") Thread.sleep(1000) - Mockito.verify(client.socket, Mockito.times(1)).send(ArgumentMatchers.any(SendingMessageBody::class.java)) + Mockito.verify(client.socket, Mockito.times(1)) + .send(ArgumentMatchers.any(SendingMessageBody::class.java)) } @Test @@ -208,24 +213,6 @@ class CallTest : BaseTest() { } } - @Test - fun `NOOP onSessionIdReceived test`() { - assertDoesNotThrow { - client = Mockito.spy(TelnyxClient(mockContext)) - client.socket = Mockito.spy( - TxSocket( - host_address = "rtc.telnyx.com", - port = 14938, - ) - ) - - call = Mockito.spy( - Call(mockContext, client, client.socket, "123", audioManager) - ) - call.onSessionIdReceived(JsonObject()) - } - } - @Test fun `NOOP onGatewayStateReceived test`() { assertDoesNotThrow { diff --git a/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/TelnyxClientTest.kt b/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/TelnyxClientTest.kt index f272b837..be5cf5dd 100644 --- a/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/TelnyxClientTest.kt +++ b/telnyx_rtc/src/test/java/com/telnyx/webrtc/sdk/TelnyxClientTest.kt @@ -404,18 +404,6 @@ class TelnyxClientTest : BaseTest() { Mockito.verify(client, Mockito.atLeast(2)).onGatewayStateReceived(anyString(), anyString()) }*/ - @Test - fun `Check error socket Error response live data is sent if a sessionID is not sent`() { - client = Mockito.spy(TelnyxClient(mockContext)) - // Lazily call buildCall() by calling arbitrary Call function - // -- call being created before connect will result in the desired socket response - client.call?.getTelnyxSessionId() - assertEquals( - client.socketResponseLiveData.getOrAwaitValue(), - SocketResponse.error("Session ID is not set, failed to build call") - ) - } - @Test fun `Test getSocketResponse returns appropriate LiveData`() { client = Mockito.spy(TelnyxClient(mockContext))