diff --git a/apps/android/app/src/main/assets/www/call.js b/apps/android/app/src/main/assets/www/call.js index b247431f4b..7267b0ba35 100644 --- a/apps/android/app/src/main/assets/www/call.js +++ b/apps/android/app/src/main/assets/www/call.js @@ -22,6 +22,7 @@ var TransformOperation; TransformOperation["Decrypt"] = "decrypt"; })(TransformOperation || (TransformOperation = {})); let activeCall; +let answerTimeout = 30000; const processCommand = (function () { const defaultIceServers = [ { urls: ["stun:stun.simplex.im:443"] }, @@ -100,9 +101,16 @@ const processCommand = (function () { const iceCandidates = getIceCandidates(pc, config); const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; await setupMediaStreams(call); + let connectionTimeout = setTimeout(connectionHandler, answerTimeout); pc.addEventListener("connectionstatechange", connectionStateChange); return call; async function connectionStateChange() { + // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) + // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) + if (pc.connectionState !== "failed") + connectionHandler(); + } + async function connectionHandler() { sendMessageToNative({ resp: { type: "connection", @@ -115,6 +123,7 @@ const processCommand = (function () { }, }); if (pc.connectionState == "disconnected" || pc.connectionState == "failed") { + clearConnectionTimeout(); pc.removeEventListener("connectionstatechange", connectionStateChange); if (activeCall) { setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0); @@ -122,6 +131,7 @@ const processCommand = (function () { endCall(); } else if (pc.connectionState == "connected") { + clearConnectionTimeout(); const stats = (await pc.getStats()); for (const stat of stats.values()) { const { type, state } = stat; @@ -141,6 +151,12 @@ const processCommand = (function () { } } } + function clearConnectionTimeout() { + if (connectionTimeout) { + clearTimeout(connectionTimeout); + connectionTimeout = undefined; + } + } } function serialize(x) { return LZString.compressToBase64(JSON.stringify(x)); diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt index 90f03cfe66..7f850a35d1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -62,39 +62,39 @@ enum class CallState { } } -@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand) -@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null) +@Serializable data class WVAPICall(val corrId: Int? = null, val command: WCallCommand) +@Serializable data class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null) @Serializable sealed class WCallCommand { @Serializable @SerialName("capabilities") object Capabilities: WCallCommand() - @Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() - @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() - @Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand() - @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand() - @Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() - @Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand() + @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() + @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() + @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() + @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() + @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("end") object End: WCallCommand() } @Serializable sealed class WCallResponse { - @Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse() - @Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse() - @Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse() - @Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse() - @Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse() - @Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse() + @Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse() + @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse() + @Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() + @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() + @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() @Serializable @SerialName("ended") object Ended: WCallResponse() @Serializable @SerialName("ok") object Ok: WCallResponse() - @Serializable @SerialName("error") class Error(val message: String): WCallResponse() + @Serializable @SerialName("error") data class Error(val message: String): WCallResponse() } -@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) -@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) -@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String) -@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities) -@Serializable class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) { +@Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) +@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) +@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) +@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) +@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) { val callTypeText: String get() = generalGetString(when(callType.media) { CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call @@ -104,8 +104,8 @@ sealed class WCallResponse { CallMediaType.Audio -> R.string.incoming_audio_call }) } -@Serializable class CallCapabilities(val encryption: Boolean) -@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { +@Serializable data class CallCapabilities(val encryption: Boolean) +@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { val text: String @Composable get() = when { localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host -> stringResource(R.string.call_connection_peer_to_peer) @@ -116,7 +116,7 @@ sealed class WCallResponse { } } // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate -@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?) +@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?) // https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer @Serializable data class RTCIceServer(val urls: List, val username: String? = null, val credential: String? = null) @@ -151,7 +151,7 @@ enum class VideoCamera { } @Serializable -class ConnectionState( +data class ConnectionState( val connectionState: String, val iceConnectionState: String, val iceGatheringState: String, diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index deae379301..69f6a4a6bd 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/webrtc", - "version": "0.1.1", + "version": "0.2.1", "description": "WebRTC call in browser and webview for SimpleX Chat clients", "main": "dist/call.js", "types": "dist/call.d.ts", diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index e62a5a419d..ec1691c8d1 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -191,6 +191,7 @@ interface Call { } let activeCall: Call | undefined +let answerTimeout = 30_000 const processCommand = (function () { type RTCRtpSenderWithEncryption = RTCRtpSender & { @@ -294,10 +295,17 @@ const processCommand = (function () { const iceCandidates = getIceCandidates(pc, config) const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} await setupMediaStreams(call) + let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) pc.addEventListener("connectionstatechange", connectionStateChange) return call async function connectionStateChange() { + // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) + // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) + if (pc.connectionState !== "failed") connectionHandler() + } + + async function connectionHandler() { sendMessageToNative({ resp: { type: "connection", @@ -310,12 +318,14 @@ const processCommand = (function () { }, }) if (pc.connectionState == "disconnected" || pc.connectionState == "failed") { + clearConnectionTimeout() pc.removeEventListener("connectionstatechange", connectionStateChange) if (activeCall) { setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } endCall() } else if (pc.connectionState == "connected") { + clearConnectionTimeout() const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { const {type, state} = stat @@ -335,6 +345,13 @@ const processCommand = (function () { } } } + + function clearConnectionTimeout() { + if (connectionTimeout) { + clearTimeout(connectionTimeout) + connectionTimeout = undefined + } + } } function serialize(x: T): string {