diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 15d36f7931..f2fc9ffb8e 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -876,7 +876,7 @@ extension WebRTCClient: RTCPeerConnectionDelegate { guard let connectionStateString = newState.toString() else { return } Task { - if activeCall?.wasConnected == true { + if activeCall?.wasConnected == true && ChatModel.shared.activeCall?.contact.callReconnectSupported == true { switch newState { case .disconnected, .failed: if activeCall?.reconnection == nil { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 8bdd952372..b40d14cd56 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -19,6 +19,8 @@ public let REPORTS_VERSION = 12 // support group knocking (MsgScope) public let GROUP_KNOCKING_VERSION = 15 +public let CALL_RECONNECT_VERSION = 22 + public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { @@ -2505,6 +2507,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var active: Bool { get { contactStatus == .active } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } + public var callReconnectSupported: Bool { (activeConn?.peerChatVRange.maxVersion ?? 0) >= CALL_RECONNECT_VERSION } public var nextConnectPrepared: Bool { active && preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) } public var profileChangeProhibited: Bool { activeConn != nil } public var nextAcceptContactRequest: Bool { diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md index e656a2932a..5032a3a3ed 100644 --- a/apps/ios/product/flows/calling.md +++ b/apps/ios/product/flows/calling.md @@ -109,7 +109,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central 2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core. 3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way. 4. When the connection is restored, the call returns to connected state. -5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case. +5. If the connection is not restored in 30 seconds, the call ends. If the contact's chat version is below 22 (`CALL_RECONNECT_VERSION`), the call ends as soon as the connection is lost, without reconnection. ### 7. Audio Routing diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md index b3fb83d151..08f256449b 100644 --- a/apps/ios/spec/services/calls.md +++ b/apps/ios/spec/services/calls.md @@ -184,7 +184,7 @@ All call signaling travels through the same encrypted SMP message channels used ### Reconnection -When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. +When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. Reconnection happens only when the contact's peer chat version is at least `CALL_RECONNECT_VERSION` (22); otherwise the call ends as soon as the connection is lost. ### API Commands diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 094615eee1..2b9c4d8201 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1917,6 +1917,7 @@ data class Contact( val active get() = contactStatus == ContactStatus.Active override val nextConnect get() = sendMsgToConnect val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent + val callReconnectSupported get() = (activeConn?.peerChatVRange?.maxVersion ?: 0) >= CALL_RECONNECT_VERSION override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) override val profileChangeProhibited get() = activeConn != null val nextAcceptContactRequest get() = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3508221858..da453bf585 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -74,6 +74,8 @@ val CREATE_MEMBER_CONTACT_VERSION = 2 // support group knocking (MsgScope) val GROUP_KNOCKING_VERSION = 15 +val CALL_RECONNECT_VERSION = 22 + enum class CallOnLockScreen { DISABLE, SHOW, @@ -3694,7 +3696,8 @@ object ChatController { media = r.callType.media, aesKey = r.sharedKey, iceServers = iceServers, - relay = useRelay + relay = useRelay, + canReconnect = r.contact.callReconnectSupported )) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index 89865689c6..3265e1c8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -62,7 +62,8 @@ class CallManager(val chatModel: ChatModel) { media = invitation.callType.media, aesKey = invitation.sharedKey, iceServers = iceServers, - relay = useRelay + relay = useRelay, + canReconnect = invitation.contact.callReconnectSupported )) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 86e826e946..6be070631f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -90,8 +90,8 @@ enum class CallState { sealed class WCallCommand { @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand() @Serializable @SerialName("permission") data class Permission(val title: String, val chrome: String, val safari: String): 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("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null, val canReconnect: Boolean = false): 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, val canReconnect: Boolean = false): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand() @Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand() diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index 6fb3677a2e..0e2c1f7b74 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -145,7 +145,7 @@ const processCommand = (function () { } }); } - async function initializeCall(config, mediaType, aesKey) { + async function initializeCall(config, mediaType, aesKey, canReconnect) { var _a, _b, _c; let pc; try { @@ -233,7 +233,7 @@ const processCommand = (function () { : pc.iceConnectionState == "completed" ? "connected" : "connecting"); /* webView 69-70 doesn't have connectionState yet */ - if (wasConnected) { + if (wasConnected && canReconnect) { if (reconnectTimedOut) { reconnectingTimeout = undefined; if (activeCall !== call || state == "connected") @@ -415,10 +415,10 @@ const processCommand = (function () { inactiveCallMediaSources.camera = command.media == CallMediaType.Video; inactiveCallMediaSourcesChanged(inactiveCallMediaSources); setupLocalVideoRatio(getVideoElements().local); - const { media, iceServers, relay } = command; + const { media, iceServers, relay, canReconnect } = command; const encryption = supportsInsertableStreams(useWorker); const aesKey = encryption ? command.aesKey : undefined; - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey); + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, canReconnect); await setupLocalStream(true, activeCall); setupCodecPreferences(activeCall); const pc = activeCall.connection; @@ -458,8 +458,8 @@ const processCommand = (function () { else { const offer = parse(command.offer); const remoteIceCandidates = parse(command.iceCandidates); - const { media, aesKey, iceServers, relay } = command; - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); + const { media, aesKey, iceServers, relay, canReconnect } = command; + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, canReconnect); const pc = activeCall.connection; // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(!webView69Or70() ? offer : adaptSdpToOldWebView(offer))); diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md index d29c1d4478..72b48d07c8 100644 --- a/apps/multiplatform/product/flows/calling.md +++ b/apps/multiplatform/product/flows/calling.md @@ -121,7 +121,7 @@ Reconnection: Connected -> Reconnecting -> Connected | Ended 2. The WebRTC engine reports `"reconnecting"` connection state; the app shows "reconnecting…" and plays the connecting sound. This state is not reported to the core, and `connectedAt` is kept, so call duration continues. 3. The callee's WebRTC engine (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer in `WCallResponse.Ice` with `session` via `apiSendCallExtraInfo`. The caller answers the same way. 4. When the connection is restored, the call returns to `Connected` and the sound stops. -5. If the connection is not restored in 30 seconds, the call ends as disconnected. Clients without ICE restart support ignore the restart offer, so the call ends in this case. +5. If the connection is not restored in 30 seconds, the call ends as disconnected. If the contact's chat version is below 22 (`CALL_RECONNECT_VERSION`), the call ends as soon as the connection is lost, without reconnection. --- diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index a46de4f45c..1bbcb81cad 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -54,7 +54,7 @@ enum class CallState { State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). -**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. +**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. Reconnection is enabled by `canReconnect` in `WCallCommand.Start` / `WCallCommand.Offer`, set when the contact's peer chat version is at least `CALL_RECONNECT_VERSION` (22); otherwise the call ends as soon as the connection is lost. --- diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index a95e7ee75f..0538978766 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -286,7 +286,7 @@ These message are used for WebRTC calls: 3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message. -4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients that do not support it ignore this property, and the call ends when reconnection times out. +4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients restart ICE only when the peer supports chat protocol version 22 or later, otherwise the call ends when the connection is lost. 5. `x.call.end` message is sent to notify the other party that the call is terminated. diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index aa207939e8..aaa2acf7e8 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -110,6 +110,7 @@ interface WCStartCall extends IWCallCommand { aesKey?: string iceServers?: RTCIceServer[] relay?: boolean + canReconnect?: boolean } interface WCEndCall extends IWCallCommand { @@ -124,6 +125,7 @@ interface WCAcceptOffer extends IWCallCommand { aesKey?: string iceServers?: RTCIceServer[] relay?: boolean + canReconnect?: boolean } interface WCallOffer extends IWCallResponse { @@ -429,7 +431,7 @@ const processCommand = (function () { }) } - async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { + async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string, canReconnect?: boolean): Promise { let pc: RTCPeerConnection try { pc = new RTCPeerConnection(config.peerConnectionConfig) @@ -520,7 +522,7 @@ const processCommand = (function () { : pc.iceConnectionState == "completed" ? "connected" : "connecting") /* webView 69-70 doesn't have connectionState yet */ - if (wasConnected) { + if (wasConnected && canReconnect) { if (reconnectTimedOut) { reconnectingTimeout = undefined if (activeCall !== call || state == "connected") return @@ -700,10 +702,10 @@ const processCommand = (function () { inactiveCallMediaSourcesChanged(inactiveCallMediaSources) setupLocalVideoRatio(getVideoElements()!.local) - const {media, iceServers, relay} = command + const {media, iceServers, relay, canReconnect} = command const encryption = supportsInsertableStreams(useWorker) const aesKey = encryption ? command.aesKey : undefined - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey) + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, canReconnect) await setupLocalStream(true, activeCall) setupCodecPreferences(activeCall) @@ -742,8 +744,8 @@ const processCommand = (function () { } else { const offer: RTCSessionDescriptionInit = parse(command.offer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - const {media, aesKey, iceServers, relay} = command - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) + const {media, aesKey, iceServers, relay, canReconnect} = command + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, canReconnect) const pc = activeCall.connection // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(!webView69Or70() ? offer : adaptSdpToOldWebView(offer))) diff --git a/packages/simplex-chat-webrtc/test/fullstack.test.js b/packages/simplex-chat-webrtc/test/fullstack.test.js index 06711845a3..e67aa92dec 100644 --- a/packages/simplex-chat-webrtc/test/fullstack.test.js +++ b/packages/simplex-chat-webrtc/test/fullstack.test.js @@ -14,6 +14,7 @@ const DISCONNECT_DETECTION = 10000 const RECONNECT = 20000 const SIGNALING = 15000 const CORE_CALL_STATUSES = ["connecting", "connected", "disconnected", "failed"] +const CALL_RECONNECT_VERSION = 22 async function withChatCall({useWorker = false}, run) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "simplex-call-test-")) @@ -78,7 +79,13 @@ async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) await alice.cmd(`/_call invite @${aliceContact} ${JSON.stringify(callType)}`) const {callInvitation} = await bob.waitEvent((e) => e.type === "callInvitation", SIGNALING, "callInvitation") - const offer = await b.command({type: "start", media: MEDIA, aesKey: callInvitation.sharedKey, iceServers: []}) + const offer = await b.command({ + type: "start", + media: MEDIA, + aesKey: callInvitation.sharedKey, + iceServers: [], + canReconnect: canReconnect(callInvitation.contact), + }) assert.equal(offer.resp.type, "offer", JSON.stringify(offer.resp)) const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) const callOffer = { @@ -95,6 +102,7 @@ async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) media: MEDIA, aesKey: offered.sharedKey, iceServers: [], + canReconnect: canReconnect(offered.contact), }) assert.equal(answer.resp.type, "answer", JSON.stringify(answer.resp)) const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) @@ -107,6 +115,10 @@ async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) await Promise.all([a.waitForState("connected", RECONNECT), b.waitForState("connected", RECONNECT)]) } +function canReconnect(contact) { + return contact.activeConn.peerChatVRange.maxVersion >= CALL_RECONNECT_VERSION +} + async function assertAudioDecoded(a, b) { const before = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] await sleep(2000) diff --git a/packages/simplex-chat-webrtc/test/harness.js b/packages/simplex-chat-webrtc/test/harness.js index d8e1668ce1..02969687ae 100644 --- a/packages/simplex-chat-webrtc/test/harness.js +++ b/packages/simplex-chat-webrtc/test/harness.js @@ -129,10 +129,9 @@ class Network { } class Peer { - constructor(name, page, {ignoresIceRestart = false} = {}) { + constructor(name, page) { this.name = name this.page = page - this.ignoresIceRestart = ignoresIceRestart this.messages = [] this.waiters = [] this.onResponse = async () => {} @@ -245,7 +244,7 @@ class Signaling { } // useWorker encrypts media in a worker with RTCRtpScriptTransform, as the desktop app does -async function openPeers({answerer = {}, useWorker = false} = {}) { +async function openPeers({useWorker = false} = {}) { const server = await servePage() const url = `http://localhost:${server.address().port}/android/call.html` const browser = await chromium.launch({ @@ -257,15 +256,15 @@ async function openPeers({answerer = {}, useWorker = false} = {}) { "--autoplay-policy=no-user-gesture-required", ], }) - const newPeer = async (name, opts) => { + const newPeer = async (name) => { const context = await browser.newContext({permissions: ["camera", "microphone"]}) - const peer = new Peer(name, await context.newPage(), opts) + const peer = new Peer(name, await context.newPage()) await peer.open(url) if (useWorker) await peer.page.evaluate(() => (useWorker = true)) return peer } // A sends the call invitation and B accepts it, so B sends the WebRTC offer, as "start" command in the apps - const a = await newPeer("A", answerer) + const a = await newPeer("A") const b = await newPeer("B") const network = new Network() const signaling = new Signaling(network) @@ -279,20 +278,20 @@ async function openPeers({answerer = {}, useWorker = false} = {}) { } // The test relays signaling between the peers directly, as the apps and the chat core would do. -async function startCall({media = "audio", answerer = {}} = {}) { - const peers = await openPeers({answerer}) +async function startCall({media = "audio", canReconnect = true} = {}) { + const peers = await openPeers() const {a, b, network, signaling, close} = peers const relayIce = (from, to) => async (resp) => { if (resp.type !== "ice") return const {session, iceCandidates} = await network.routeSignal(from.name, resp.session, resp.iceCandidates) - signaling.send(() => to.command({type: "ice", iceCandidates, session: to.ignoresIceRestart ? undefined : session})) + signaling.send(() => to.command({type: "ice", iceCandidates, session})) } a.onResponse = relayIce(a, b) b.onResponse = relayIce(b, a) try { await a.command({type: "capabilities", media}) - const offer = await b.command({type: "start", media, iceServers: []}) + const offer = await b.command({type: "start", media, iceServers: [], canReconnect}) if (offer.resp.type !== "offer") throw new Error(`B start: ${JSON.stringify(offer.resp)}`) const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) const answer = await a.command({ @@ -301,6 +300,7 @@ async function startCall({media = "audio", answerer = {}} = {}) { iceCandidates: offerSignal.iceCandidates, media, iceServers: [], + canReconnect, }) if (answer.resp.type !== "answer") throw new Error(`A offer: ${JSON.stringify(answer.resp)}`) const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) diff --git a/packages/simplex-chat-webrtc/test/reconnect.test.js b/packages/simplex-chat-webrtc/test/reconnect.test.js index d7eb9f861e..74667df7c6 100644 --- a/packages/simplex-chat-webrtc/test/reconnect.test.js +++ b/packages/simplex-chat-webrtc/test/reconnect.test.js @@ -83,13 +83,15 @@ test("call reconnection", {concurrency: true}, async (t) => { }) ) - await t.test("ends the call when the peer does not support ICE restart", () => - withCall({answerer: {ignoresIceRestart: true}}, async ({a, b, network}) => { - await Promise.all([a.setTimeouts({reconnectTimeout: 10000}), b.setTimeouts({reconnectTimeout: 10000})]) - network.changePath() - await Promise.all([a.waitForEnded(DISCONNECT_DETECTION + 15000), b.waitForEnded(DISCONNECT_DETECTION + 15000)]) - assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") - assert.equal(a.sessionsSent(), 0) + await t.test("ends the call without reconnecting when the peer does not support ICE restart", () => + withCall({canReconnect: false}, async ({a, b, network}) => { + network.down = true + await Promise.all([a.waitForEnded(DISCONNECT_DETECTION), b.waitForEnded(DISCONNECT_DETECTION)]) + assert.ok(!a.connectionStates().includes("reconnecting"), `A states: ${a.connectionStates()}`) + assert.ok(!b.connectionStates().includes("reconnecting"), `B states: ${b.connectionStates()}`) + assert.equal(a.connectionStates().at(-1), "disconnected") + assert.equal(b.connectionStates().at(-1), "disconnected") + assert.equal(a.sessionsSent() + b.sessionsSent(), 0, "ICE was restarted") }) ) })