diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 60ecfea09c..fb1d78f650 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1850,8 +1850,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) } -func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws { - let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) +func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws { + let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) } @@ -2987,7 +2987,7 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .callExtraInfo(_, contact, extraInfo): await withCall(contact) { _ in - await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) + await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession)) } case let .callEnded(_, contact): if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 754bcb2715..a2e65dcf71 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -161,10 +161,10 @@ struct ActiveCallView: View { CallSoundsPlayer.shared.stop() } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): Task { do { - try await apiSendCallExtraInfo(call.contact, iceCandidates) + try await apiSendCallExtraInfo(call.contact, session, iceCandidates) } catch { logger.error("apiSendCallExtraInfo \(responseError(error))") } @@ -172,15 +172,24 @@ struct ActiveCallView: View { case let .connection(state): if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { - call.direction == .outgoing - ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) - : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + // connectedAt is kept on reconnection, so that call duration is not reset + if call.connectedAt == nil { + call.direction == .outgoing + ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + call.connectedAt = .now + } call.callState = .connected - call.connectedAt = .now + CallSoundsPlayer.shared.stop() if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true } + } else if state.connectionState == "reconnecting" { + if call.callState != .reconnecting { + CallSoundsPlayer.shared.startConnectingCallSound() + } + call.callState = .reconnecting } if state.connectionState == "closed" { closeCallView(client) @@ -190,17 +199,22 @@ struct ActiveCallView: View { m.activeCall = nil m.activeCallViewIsCollapsed = false } - Task { - do { - try await apiCallStatus(call.contact, state.connectionState) - } catch { - logger.error("apiCallStatus \(responseError(error))") + // "reconnecting" is not a core call status + if state.connectionState != "reconnecting" { + Task { + do { + try await apiCallStatus(call.contact, state.connectionState) + } catch { + logger.error("apiCallStatus \(responseError(error))") + } } } case let .connected(connectionInfo): call.callState = .connected call.connectionInfo = connectionInfo - call.connectedAt = .now + if call.connectedAt == nil { + call.connectedAt = .now + } if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ef9135761c..6f7a0efa38 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -77,6 +77,7 @@ enum CallState { case answerReceived // incoming - webrtc answer received via API case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed case connected + case reconnecting case ended var text: LocalizedStringKey { @@ -89,6 +90,7 @@ enum CallState { case .answerReceived: return "received confirmation…" case .negotiated: return "connecting…" case .connected: return "connected" + case .reconnecting: return "reconnecting…" case .ended: return "ended" } } @@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case media(source: CallMediaSource, enable: Bool) case end @@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case offer case answer case iceCandidates + case session case enable case iceServers case relay @@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .media(source, enable): try container.encode("media", forKey: .type) try container.encode(source, forKey: .media) @@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "media": let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) @@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable { case capabilities(capabilities: CallCapabilities) case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case connection(state: ConnectionState) case connected(connectionInfo: ConnectionInfo) case peerMedia(source: CallMediaSource, enabled: Bool) @@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable { case offer case answer case iceCandidates + case session case state case connectionInfo case message @@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "connection": let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state) self = .connection(state: state) @@ -333,9 +340,10 @@ extension WCallResponse: Encodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .connection(state): try container.encode("connection", forKey: .type) try container.encode(state, forKey: .state) diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index c7d53d3c69..15d36f7931 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg }() private static let ivTagBytes: Int = 28 private static let enableEncryption: Bool = true + private static let reconnectTimeout: UInt64 = 30_000_000_000 + private static let iceRestartInterval: UInt64 = 10_000_000_000 private var chat_ctrl = getChatCtrl() struct Call { @@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg var frameEncryptor: RTCFrameEncryptor? var frameDecryptor: RTCFrameDecryptor? var peerHasOldVersion: Bool + var wasConnected = false + var reconnection: Task? = nil + var iceRestarts: Task? = nil } struct NotConnectedCall { @@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .error(message: "answer: remote description is not set") } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): if let pc = pc, let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { - addIceCandidates(pc, remoteIceCandidates) - resp = .ok + if let session { + resp = await receiveRestartSession(pc, session, remoteIceCandidates) + } else { + addIceCandidates(pc, remoteIceCandidates) + resp = .ok + } } else { resp = .error(message: "ice: call not started") } @@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + private func startReconnecting(_ connection: RTCPeerConnection) async { + activeCall?.reconnection = Task { + _ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout) + if Task.isCancelled { return } + await sendConnectionState(connection, "disconnected") + endCall() + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + if connection.localDescription?.type == .offer { + activeCall?.iceRestarts = Task { + while !Task.isCancelled { + await restartIce(connection) + _ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval) + } + } + } + await sendConnectionState(connection, "reconnecting") + } + + private func stopReconnecting() { + activeCall?.reconnection?.cancel() + activeCall?.reconnection = nil + activeCall?.iceRestarts?.cancel() + activeCall?.iceRestarts = nil + } + + private func restartIce(_ connection: RTCPeerConnection) async { + // the previous restart offer is not answered yet + if connection.signalingState != .stable { return } + let (offer, error) = await connection.offer(iceRestart: true) + if let offer { + await sendRestartSession(offer) + } else { + logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")") + } + } + + private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse { + guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)), + let type = desc.type, let sdp = desc.sdp else { + return .error(message: "ice: invalid session") + } + let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp) + if type == .offer && pc.localDescription?.type != .offer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote offer is not set") + } + let (answer, error) = await pc.answer() + guard let answer else { + return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")") + } + addIceCandidates(pc, remoteIceCandidates) + await sendRestartSession(answer) + } else if type == .answer && pc.signalingState == .haveLocalOffer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote answer is not set") + } + addIceCandidates(pc, remoteIceCandidates) + } else { + return .error(message: "ice: unexpected ICE restart \(type.rawValue)") + } + return .ok + } + + private func sendRestartSession(_ desc: RTCSessionDescription) async { + await sendCallResponse(.init( + corrId: nil, + resp: .ice( + iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())), + session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp))) + ), + command: nil) + ) + waitForMoreIceCandidates() + } + + private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async { + guard let iceConnectionState = connection.iceConnectionState.toString(), + let iceGatheringState = connection.iceGatheringState.toString(), + let signalingState = connection.signalingState.toString() + else { + return + } + await sendCallResponse(.init( + corrId: nil, + resp: .connection(state: ConnectionState( + connectionState: connectionState, + iceConnectionState: iceConnectionState, + iceGatheringState: iceGatheringState, + signalingState: signalingState) + ), + command: nil) + ) + } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( @@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg (notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture() guard let call = activeCall else { return } logger.debug("WebRTCClient: ending the call") + call.reconnection?.cancel() + call.iceRestarts?.cancel() call.connection.close() call.connection.delegate = nil call.frameEncryptor?.delegate = nil @@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg extension WebRTC.RTCPeerConnection { - func mediaConstraints() -> RTCMediaConstraints { - RTCMediaConstraints( - mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, - kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue], - optionalConstraints: nil) + func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints { + var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, + kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue] + if iceRestart { + constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue + } + return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil) } - func offer() async -> (RTCSessionDescription?, Error?) { + func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) { await withCheckedContinuation { cont in - offer(for: mediaConstraints()) { (sdp, error) in + offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in self.processSDP(cont, sdp, error) } } @@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)") - guard let connectionStateString = newState.toString(), - let iceConnectionStateString = connection.iceConnectionState.toString(), - let iceGatheringStateString = connection.iceGatheringState.toString(), - let signalingStateString = connection.signalingState.toString() - else { - return - } + guard let connectionStateString = newState.toString() else { return } Task { - await sendCallResponse(.init( - corrId: nil, - resp: .connection(state: ConnectionState( - connectionState: connectionStateString, - iceConnectionState: iceConnectionStateString, - iceGatheringState: iceGatheringStateString, - signalingState: signalingStateString) - ), - command: nil) - ) + if activeCall?.wasConnected == true { + switch newState { + case .disconnected, .failed: + if activeCall?.reconnection == nil { + await startReconnecting(connection) + } + return + case .new, .checking: return + default: () + } + } + await sendConnectionState(connection, connectionStateString) switch newState { case .checking: @@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate { } let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true setSpeakerEnabledAndConfigureSession(enableSpeaker) - case .connected: sendConnectedEvent(connection) + case .connected: + stopReconnecting() + activeCall?.wasConnected = true + sendConnectedEvent(connection) case .disconnected, .failed: endCall() default: () } diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index ece65130e6..53830bc68f 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -34,10 +34,12 @@ public struct WebRTCSession: Codable { // Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { - public init(rtcIceCandidates: String) { + public init(rtcSession: String?, rtcIceCandidates: String) { + self.rtcSession = rtcSession self.rtcIceCandidates = rtcIceCandidates } + public var rtcSession: String? public var rtcIceCandidates: String } diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md index 86cb026625..e656a2932a 100644 --- a/apps/ios/product/flows/calling.md +++ b/apps/ios/product/flows/calling.md @@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central 5. `CallViewRenderers` manages WebRTC video rendering surfaces. 6. Call status updates are sent via `apiCallStatus(contact:status:)`. +### 6.1 Reconnection + +1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network. +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. + ### 7. Audio Routing 1. `CallAudioDeviceManager` handles audio device selection. @@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central | `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | | `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | | `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | -| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer | | `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | | `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | | `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | @@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central |-------|-------|----------| | Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | | Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | -| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds | | CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | | No camera/mic permission | User denied permissions | Permission request dialog shown | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 56c3c34cb6..cefc76dd19 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | | `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | | `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | -| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) | | `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | | `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | | `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md index 6a1d89f6a3..b3fb83d151 100644 --- a/apps/ios/spec/services/calls.md +++ b/apps/ios/spec/services/calls.md @@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels. | [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | | [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | | [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | -| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 | +| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 | +| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 | +| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 | | [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | | [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | | [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | @@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used 5. P2P connection established ``` +### 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. + ### API Commands | Command | Direction | Purpose | @@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used | `apiRejectCall(contact:)` | Callee -> Caller | Reject call | | `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | | `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | -| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` | | `apiEndCall(contact:)` | Either | End call | | `apiGetCallInvitations` | -- | Get pending invitations | | `apiCallStatus(contact:, callStatus:)` | -- | Report status change | @@ -317,10 +324,11 @@ struct WebRTCSession { } ``` -### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45) ```swift struct WebRTCExtraInfo { + var rtcSession: String? // ICE restart offer or answer var rtcIceCandidates: String // Additional ICE candidates } ``` @@ -330,7 +338,7 @@ struct WebRTCExtraInfo { Stored in `ChatModel.activeCall`: - Contact reference - Call UUID -- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.) - Media type - WebRTCClient reference diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index dfe38f197a..d4c533fd4e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -52,8 +52,7 @@ import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import java.io.Closeable @@ -168,13 +167,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) } + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) } } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM val wv = webView.value if (wv != null) { LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(wv, cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(wv, cmd) } } + } } } val assetLoader = WebViewAssetLoader.Builder() 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 54d684160e..3508221858 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 @@ -2185,8 +2185,8 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { - val extraInfo = WebRTCExtraInfo(rtcIceCandidates) + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean { + val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r.result is CR.CmdOk } @@ -3436,7 +3436,7 @@ object ChatController { } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession)) } } is CR.CallEnded -> { 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 b761218fdc..86e826e946 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 @@ -56,6 +56,7 @@ enum class CallState { AnswerReceived, Negotiated, Connected, + Reconnecting, Ended; val text: String get() = when(this) { @@ -67,6 +68,7 @@ enum class CallState { AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) Negotiated -> generalGetString(MR.strings.callstate_connecting) Connected -> generalGetString(MR.strings.callstate_connected) + Reconnecting -> generalGetString(MR.strings.callstate_reconnecting) Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -91,7 +93,7 @@ sealed class 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("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() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @@ -104,7 +106,7 @@ sealed class 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("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() @Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse() @@ -116,7 +118,7 @@ sealed class WCallResponse { @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 WebRTCExtraInfo(val rtcSession: String? = null, val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable data class RcvCallInvitation( val remoteHostId: Long?, diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 48c844acce..cd80492607 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -1301,6 +1301,7 @@ received answer… received confirmation… connecting… + reconnecting… connected ended 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 7ab8d6fdd6..6fb3677a2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -54,6 +54,8 @@ let inactiveCallMediaSources = { let activeCall; let notConnectedCall; let answerTimeout = 30000; +let reconnectTimeout = 30000; +let iceRestartInterval = 10000; var useWorker = false; var isDesktop = false; var localizedState = ""; @@ -75,7 +77,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -202,10 +205,14 @@ const processCommand = (function () { layout: (_c = notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.layout) !== null && _c !== void 0 ? _c : LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, }; localOrPeerMediaSourcesChanged(call); await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); + let reconnectingTimeout; + let iceRestartTimeout; + let wasConnected = false; if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange); } @@ -216,30 +223,49 @@ const processCommand = (function () { 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") + if (pc.connectionState !== "failed" || wasConnected) connectionHandler(); } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { var _a; + let state = (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting"); /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined; + if (activeCall !== call || state == "connected") + return; + state = "disconnected"; + } + else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) + return; + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout); + if (isIceRestarter()) + restartIce(); + state = "reconnecting"; + } + else if (state == "connecting" || state == "new") { + return; + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }); - if (pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed"))) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout(); + clearReconnecting(); if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange); } @@ -251,8 +277,10 @@ const processCommand = (function () { } endCall(); } - else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + else if (state == "connected") { clearConnectionTimeout(); + clearReconnecting(); + wasConnected = true; const stats = (await pc.getStats()); for (const stat of stats.values()) { const { type, state } = stat; @@ -278,6 +306,53 @@ const processCommand = (function () { connectionTimeout = undefined; } } + function clearReconnecting() { + clearTimeout(reconnectingTimeout); + reconnectingTimeout = undefined; + clearTimeout(iceRestartTimeout); + iceRestartTimeout = undefined; + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter() { + var _a; + return ((_a = pc.localDescription) === null || _a === void 0 ? void 0 : _a.type) == "offer"; + } + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) + return; + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval); + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") + return; + try { + const iceCandidates = getIceCandidates(pc, config); + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + sendMessageToNative({ resp: { type: "ice", session: serialize(offer), iceCandidates: await iceCandidates } }); + } + catch (e) { + console.log("ICE restart error", e); + } + } + async function receiveRestartSession(session, iceCandidates) { + const desc = parse(session); + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)); + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc); + const localIceCandidates = getIceCandidates(pc, config); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + addIceCandidates(pc, iceCandidates); + sendMessageToNative({ resp: { type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates } }); + } + else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc); + addIceCandidates(pc, iceCandidates); + } + else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`); + } + } } function serialize(x) { return LZString.compressToBase64(JSON.stringify(x)); @@ -296,6 +371,8 @@ const processCommand = (function () { console.log("starting outgoing call - capabilities"); if (activeCall) endCall(); + // Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here) + stopNotConnectedCall(); let localStream = null; try { localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User); @@ -332,7 +409,8 @@ const processCommand = (function () { if (activeCall) endCall(); // It can be already defined on Android when switching calls (if the previous call was outgoing) - notConnectedCall = undefined; + // Stop its preview tracks before clearing, otherwise camera/mic stay live + stopNotConnectedCall(); inactiveCallMediaSources.mic = true; inactiveCallMediaSources.camera = command.media == CallMediaType.Video; inactiveCallMediaSourcesChanged(inactiveCallMediaSources); @@ -436,7 +514,11 @@ const processCommand = (function () { break; case "ice": const remoteIceCandidates = parse(command.iceCandidates); - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates); + resp = { type: "ok" }; + } + else if (pc) { addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; } @@ -1131,6 +1213,13 @@ const processCommand = (function () { activeCall.localScreenStream.getTracks().forEach((track) => track.stop()); } } + // Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live. + function stopNotConnectedCall() { + if (notConnectedCall) { + notConnectedCall.localStream.getTracks().forEach((track) => track.stop()); + notConnectedCall = undefined; + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index e6828817ee..be1c820b4e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -2,7 +2,7 @@ // Override defaults to enable worker on Chrome and Safari useWorker = typeof window.Worker !== "undefined"; isDesktop = true; -// Create WebSocket connection. +// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server. const socket = new WebSocket(`ws://${location.host}${location.search}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index f22ed52357..e5542d5da1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -313,7 +313,9 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface { override fun startConnectingCallSound(scope: CoroutineScope) { // Taken from https://github.com/TelegramOrg/Telegram-Android // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE - start("/media/connecting_call.mp3", 3000, scope) + // the delay is the length of the track exactly, so it repeats with no delay, which already happens on ios and android + // Android (isLooping) and iOS (which sleeps for the track duration) + start("/media/connecting_call.mp3", 3536, scope) } override fun startInCallSound(scope: CoroutineScope) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 881b9ee168..616f46dab6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -8,6 +8,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString @@ -55,13 +56,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + chatModel.activeCall.value = call.copy(callState = CallState.Reconnecting) + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = call.connectedAt ?: Clock.System.now()) } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -194,21 +206,20 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (connections.isEmpty()) { - delay(100) - } - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(cmd) } } + } } } diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md index fae7f42031..d29c1d4478 100644 --- a/apps/multiplatform/product/flows/calling.md +++ b/apps/multiplatform/product/flows/calling.md @@ -99,6 +99,7 @@ Both platforms share a common signaling flow through the Haskell core API. ``` Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +Reconnection: Connected -> Reconnecting -> Connected | Ended ``` | State | Description | @@ -111,8 +112,17 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | | `Connected` | Media flowing | +| `Reconnecting` | Connection lost, waiting for ICE restart | | `Ended` | Call terminated | +### 3.1 Reconnection + +1. A connected call loses its WebRTC connection, e.g. when the device switches between Wi-Fi and mobile network. +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. + --- ## 4. Ending a Call @@ -184,7 +194,7 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `apiRejectCall(rh, contact)` | Reject incoming call | | `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | | `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | -| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiSendCallExtraInfo(rh, contact, rtcSession, rtcIceCandidates)` | Send additional ICE candidates, or ICE restart offer/answer | | `apiEndCall(rh, contact)` | End active call | | `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md index 51d323874c..1d4f3b881b 100644 --- a/apps/multiplatform/product/views/call.md +++ b/apps/multiplatform/product/views/call.md @@ -71,8 +71,8 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | | 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | | 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | -| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | -| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates, and ICE restart offer/answer in `session`, exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success, `CallState.Reconnecting` when a connected call loses connection | | 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | | 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | | 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | @@ -90,14 +90,15 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | `OfferReceived` | Callee received SDP offer | | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | -| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set on the first connection | +| `Reconnecting` | Connection lost; "reconnecting…" shown while ICE restarts, call ends after 30 seconds without connection | | `Ended` | Call terminated | ### Call Sounds | Sound | Trigger | |---|---| -| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent, and while reconnecting | | In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | | Ringtone | `SoundPlayer.start()` for incoming calls | | End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index 4114e9de4f..ec7cedb715 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -225,7 +225,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k | `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | | `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | | `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | -| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String` | Send additional ICE candidates or ICE restart offer/answer during a call | [L2156](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | | `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | | `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index 85303e1dad..a46de4f45c 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -43,6 +43,7 @@ enum class CallState { AnswerReceived, // SDP answer received from peer Negotiated, // ICE negotiation in progress Connected, // Media flowing + Reconnecting, // Connection lost, waiting for ICE to recover Ended; // Call terminated } ``` @@ -53,6 +54,8 @@ 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. + --- @@ -140,7 +143,7 @@ Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/com | `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | | `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | | `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | -| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiSendCallExtraInfo` | [L2156](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | Send additional ICE candidates discovered after initial exchange, or ICE restart offer/answer in `rtcSession` | | `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | | `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md index b8db78dbb1..087a7e6fef 100644 --- a/packages/simplex-chat-webrtc/README.md +++ b/packages/simplex-chat-webrtc/README.md @@ -4,3 +4,20 @@ npm i npm run build ``` + +## Tests + +End-to-end tests run two headless Chromium peers with `call.js` and simulate network outages by relaying media via UDP sockets controlled by the test: + +``` +npx playwright-core install chromium +npm test +``` + +Set `CHROME` to use another Chromium executable, and `VERBOSE=1` to log signaling and connection states. + +Full-stack tests in `test/fullstack.test.js` also pass call signaling via two chat cores and a local SMP server. They run when `SIMPLEX_CHAT` and `SMP_SERVER` are set to `simplex-chat` and `smp-server` executables: + +``` +SIMPLEX_CHAT=path/to/simplex-chat SMP_SERVER=path/to/smp-server npm test +``` diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index a6598b6ce7..e49a1484c2 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -8,7 +8,7 @@ "dist" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "tsc && node --test test/*.test.js", "build": "prettier --write --ignore-unknown . && tsc && ./copy" }, "repository": { @@ -31,6 +31,7 @@ "husky": "^7.0.4", "isomorphic-webcrypto": "^2.3.8", "lint-staged": "^12.4.1", + "playwright-core": "^1.64.0", "prettier": "^2.6.2", "typescript": "^4.6.4" }, diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 8441560013..aa207939e8 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -142,6 +142,7 @@ interface WCallAnswer extends IWCallCommand, IWCallResponse { interface WCallIceCandidates extends IWCallCommand, IWCallResponse { type: "ice" iceCandidates: string // JSON strings for RTCIceCandidateInit[] + session?: string // JSON string for RTCSessionDescriptionInit of ICE restart offer or answer } interface WCEnableMedia extends IWCallCommand { @@ -281,6 +282,7 @@ interface Call { // controls whether transceiver's track was replaced already or set initially (in video call) cameraTrackWasSetBefore: boolean peerHasOldVersion: boolean + receiveRestartSession: (session: string, iceCandidates: RTCIceCandidateInit[]) => Promise } interface NotConnectedCall { @@ -306,6 +308,8 @@ let inactiveCallMediaSources: CallMediaSources = { let activeCall: Call | undefined let notConnectedCall: NotConnectedCall | undefined let answerTimeout = 30_000 +let reconnectTimeout = 30_000 +let iceRestartInterval = 10_000 var useWorker = false var isDesktop = false var localizedState = "" @@ -357,7 +361,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers ?? defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -486,10 +491,14 @@ const processCommand = (function () { layout: notConnectedCall?.layout ?? LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, } localOrPeerMediaSourcesChanged(call) await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) + let reconnectingTimeout: number | undefined + let iceRestartTimeout: number | undefined + let wasConnected = false if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange) } else { @@ -500,33 +509,45 @@ const processCommand = (function () { 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() + if (pc.connectionState !== "failed" || wasConnected) connectionHandler() } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { + let state: string = + pc.connectionState ?? + (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting") /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined + if (activeCall !== call || state == "connected") return + state = "disconnected" + } else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) return + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout) + if (isIceRestarter()) restartIce() + state = "reconnecting" + } else if (state == "connecting" || state == "new") { + return + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: - pc.connectionState ?? - (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }) - if ( - pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed")) - ) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout() + clearReconnecting() if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange) } else { @@ -536,8 +557,10 @@ const processCommand = (function () { setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } endCall() - } else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + } else if (state == "connected") { clearConnectionTimeout() + clearReconnecting() + wasConnected = true const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { const {type, state} = stat @@ -564,6 +587,51 @@ const processCommand = (function () { connectionTimeout = undefined } } + + function clearReconnecting() { + clearTimeout(reconnectingTimeout) + reconnectingTimeout = undefined + clearTimeout(iceRestartTimeout) + iceRestartTimeout = undefined + } + + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter(): boolean { + return pc.localDescription?.type == "offer" + } + + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) return + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval) + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") return + try { + const iceCandidates = getIceCandidates(pc, config) + const offer = await pc.createOffer({iceRestart: true}) + await pc.setLocalDescription(offer) + sendMessageToNative({resp: {type: "ice", session: serialize(offer), iceCandidates: await iceCandidates}}) + } catch (e) { + console.log("ICE restart error", e) + } + } + + async function receiveRestartSession(session: string, iceCandidates: RTCIceCandidateInit[]) { + const desc: RTCSessionDescriptionInit = parse(session) + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)) + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc) + const localIceCandidates = getIceCandidates(pc, config) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + addIceCandidates(pc, iceCandidates) + sendMessageToNative({resp: {type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates}}) + } else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc) + addIceCandidates(pc, iceCandidates) + } else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`) + } + } } function serialize(x: T): string { @@ -729,7 +797,10 @@ const processCommand = (function () { break case "ice": const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates) + resp = {type: "ok"} + } else if (pc) { addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} } else { diff --git a/packages/simplex-chat-webrtc/test/chat.js b/packages/simplex-chat-webrtc/test/chat.js new file mode 100644 index 0000000000..bf35f775d7 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/chat.js @@ -0,0 +1,147 @@ +// Runs a local SMP server and chat cores with WebSocket API, so that call signaling goes via the chat protocol. +const {spawn, execFileSync} = require("child_process") +const fs = require("fs") +const net = require("net") +const path = require("path") +const {sleep, log} = require("./harness") + +const START_TIMEOUT = 30000 + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const {port} = server.address() + server.close(() => resolve(port)) + }) + }) +} + +async function waitUntil(what, check) { + const deadline = Date.now() + START_TIMEOUT + while (!(await check())) { + if (Date.now() > deadline) throw new Error(`${what} did not start in ${START_TIMEOUT}ms`) + await sleep(200) + } +} + +function canConnect(port) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +} + +function spawnLogged(name, bin, args, env) { + const proc = spawn(bin, args, {env, stdio: ["ignore", "pipe", "pipe"]}) + const output = (data) => log(`${name}:`, data.toString().trimEnd()) + proc.stdout.on("data", output) + proc.stderr.on("data", output) + return proc +} + +async function startSmpServer(bin, dir) { + const env = {...process.env, SMP_SERVER_CFG_PATH: path.join(dir, "cfg"), SMP_SERVER_LOG_PATH: path.join(dir, "log")} + const init = ["init", "--ip", "127.0.0.1", "--no-password", "--disable-web", "--disable-store-log", "-y"] + const fingerprint = execFileSync(bin, init, {env, stdio: ["ignore", "pipe", "pipe"]}) + .toString() + .match(/Fingerprint: (\S+)/)[1] + const port = await freePort() + const iniFile = path.join(dir, "cfg", "smp-server.ini") + const ini = fs + .readFileSync(iniFile, "utf8") + .replace(/^port = .*$/m, `port = ${port}`) + .replace(/^host = .*$/m, "host = localhost") + fs.writeFileSync(iniFile, ini) + const proc = spawnLogged("smp-server", bin, ["start"], env) + await waitUntil("SMP server", () => canConnect(port)) + return {address: `smp://${fingerprint}@localhost:${port}`, stop: () => proc.kill()} +} + +class ChatClient { + constructor(name, proc, ws) { + this.name = name + this.proc = proc + this.ws = ws + this.corrId = 0 + this.pending = new Map() + this.events = [] + this.waiters = [] + this.onEvent = async () => {} + ws.addEventListener("message", (e) => this.receive(JSON.parse(e.data))) + } + + static async start(bin, dir, name, smpServer) { + const port = await freePort() + const args = ["-d", path.join(dir, name), "-s", smpServer, "-p", String(port), "--user-display-name", name] + const proc = spawnLogged(`chat ${name}`, bin, args, process.env) + await waitUntil(`chat ${name}`, () => canConnect(port)) + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, {once: true}) + ws.addEventListener("error", reject, {once: true}) + }) + return new ChatClient(name, proc, ws) + } + + receive({corrId, resp}) { + if (corrId) { + this.pending.get(corrId)?.(resp) + this.pending.delete(corrId) + return + } + log(`${this.name} event:`, resp.type) + this.events.push(resp) + this.onEvent(resp).catch((e) => log(`${this.name} event error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(resp) && (w.resolve(resp), true))) + } + + async cmd(cmd) { + const corrId = String(++this.corrId) + log(`${this.name} cmd:`, cmd.slice(0, 100)) + const resp = await new Promise((resolve) => { + this.pending.set(corrId, resolve) + this.ws.send(JSON.stringify({corrId, cmd})) + }) + if (resp.type === "chatCmdError") throw new Error(`${this.name} ${cmd.split(" ", 2).join(" ")}: ${JSON.stringify(resp)}`) + return resp + } + + waitEvent(pred, timeout, what) { + const found = this.events.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (resp) => { + clearTimeout(timer) + resolve(resp) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} event in ${timeout}ms`)) + }, timeout) + }) + } + + stop() { + this.ws.close() + this.proc.kill() + } +} + +async function connectContacts(alice, bob) { + const inv = await alice.cmd("/c") + await bob.cmd(`/c ${inv.connLinkInvitation.connFullLink}`) + const connected = (c) => c.waitEvent((e) => e.type === "contactConnected", START_TIMEOUT, "contactConnected") + const [a, b] = await Promise.all([connected(alice), connected(bob)]) + return [a.contact.contactId, b.contact.contactId] +} + +module.exports = {startSmpServer, ChatClient, connectContacts} diff --git a/packages/simplex-chat-webrtc/test/fullstack.test.js b/packages/simplex-chat-webrtc/test/fullstack.test.js new file mode 100644 index 0000000000..06711845a3 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/fullstack.test.js @@ -0,0 +1,188 @@ +// Calls between two chat cores connected via a local SMP server, with the test playing the native app: +// it passes messages between call.js and the core API as the Android and desktop apps do. +const {test} = require("node:test") +const assert = require("node:assert/strict") +const fs = require("fs") +const os = require("os") +const path = require("path") +const {openPeers, sleep} = require("./harness") +const {startSmpServer, ChatClient, connectContacts} = require("./chat") + +const {SIMPLEX_CHAT, SMP_SERVER} = process.env +const MEDIA = "audio" +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 +const SIGNALING = 15000 +const CORE_CALL_STATUSES = ["connecting", "connected", "disconnected", "failed"] + +async function withChatCall({useWorker = false}, run) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "simplex-call-test-")) + const stops = [] + try { + const smp = await startSmpServer(SMP_SERVER, path.join(dir, "smp")) + stops.push(smp.stop) + const alice = await ChatClient.start(SIMPLEX_CHAT, dir, "alice", smp.address) + stops.push(() => alice.stop()) + const bob = await ChatClient.start(SIMPLEX_CHAT, dir, "bob", smp.address) + stops.push(() => bob.stop()) + const [aliceContact, bobContact] = await connectContacts(alice, bob) + const peers = await openPeers({useWorker}) + stops.push(peers.close) + // alice calls bob: a is alice's call.js, b is bob's call.js + const call = {...peers, alice, bob, aliceContact, bobContact} + connectApp(call.a, alice, aliceContact, peers) + connectApp(call.b, bob, bobContact, peers) + await startCall(call) + await run(call) + } finally { + for (const stop of stops.reverse()) await stop() + fs.rmSync(dir, {recursive: true, force: true}) + } +} + +function connectApp(peer, chat, contactId, {network, signaling}) { + peer.onResponse = async (resp) => { + switch (resp.type) { + case "ice": { + const {session, iceCandidates} = await network.routeSignal(peer.name, resp.session, resp.iceCandidates) + const extraInfo = JSON.stringify({rtcSession: session, rtcIceCandidates: iceCandidates}) + signaling.send(() => chat.cmd(`/_call extra @${contactId} ${extraInfo}`)) + break + } + case "connection": + if (CORE_CALL_STATUSES.includes(resp.state.connectionState)) { + await chat.cmd(`/_call status @${contactId} ${resp.state.connectionState}`) + } + break + case "ended": + await chat.cmd(`/_call end @${contactId}`).catch(() => {}) + break + } + } + chat.onEvent = async (event) => { + switch (event.type) { + case "callExtraInfo": + await peer.command({type: "ice", iceCandidates: event.extraInfo.rtcIceCandidates, session: event.extraInfo.rtcSession}) + break + case "callEnded": + await peer.command({type: "end"}) + break + } + } +} + +async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) { + const capabilities = await a.command({type: "capabilities", media: MEDIA}) + assert.equal(capabilities.resp.type, "capabilities") + const callType = {media: MEDIA, capabilities: capabilities.resp.capabilities} + 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: []}) + assert.equal(offer.resp.type, "offer", JSON.stringify(offer.resp)) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const callOffer = { + callType: {media: MEDIA, capabilities: offer.resp.capabilities}, + rtcSession: {rtcSession: offerSignal.session, rtcIceCandidates: offerSignal.iceCandidates}, + } + await bob.cmd(`/_call offer @${bobContact} ${JSON.stringify(callOffer)}`) + + const offered = await alice.waitEvent((e) => e.type === "callOffer", SIGNALING, "callOffer") + const answer = await a.command({ + type: "offer", + offer: offered.offer.rtcSession, + iceCandidates: offered.offer.rtcIceCandidates, + media: MEDIA, + aesKey: offered.sharedKey, + iceServers: [], + }) + assert.equal(answer.resp.type, "answer", JSON.stringify(answer.resp)) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const callAnswer = {rtcSession: answerSignal.session, rtcIceCandidates: answerSignal.iceCandidates} + await alice.cmd(`/_call answer @${aliceContact} ${JSON.stringify(callAnswer)}`) + + const answered = await bob.waitEvent((e) => e.type === "callAnswer", SIGNALING, "callAnswer") + const ok = await b.command({type: "answer", answer: answered.answer.rtcSession, iceCandidates: answered.answer.rtcIceCandidates}) + assert.equal(ok.resp.type, "ok", JSON.stringify(ok.resp)) + await Promise.all([a.waitForState("connected", RECONNECT), b.waitForState("connected", RECONNECT)]) +} + +async function assertAudioDecoded(a, b) { + const before = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + await sleep(2000) + const after = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + assert.ok(after[0] > before[0], `A decoded no audio: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B decoded no audio: ${before[1]} -> ${after[1]}`) +} + +async function callItem(chat, contactId) { + const r = await chat.cmd(`/_get chat @${contactId} count=1`) + return r.chat.chatItems[0].content +} + +function restartSessionsReceived(chat) { + return chat.events.filter((e) => e.type === "callExtraInfo" && e.extraInfo.rtcSession).length +} + +const skip = !(SIMPLEX_CHAT && SMP_SERVER) && "set SIMPLEX_CHAT and SMP_SERVER to chat CLI and SMP server executables" + +test("calls via chat core", {skip}, async (t) => { + for (const [app, useWorker] of [ + ["android", false], + ["desktop", true], + ]) { + await t.test(`${app}: encrypted call reconnects with ICE restart after network path change`, () => + withChatCall({useWorker}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + assert.ok(a.messages.find((m) => m.resp.type === "capabilities").resp.capabilities.encryption, "call is not encrypted") + assert.equal(await a.page.evaluate(() => !!activeCall.worker), useWorker, "encryption worker") + await assertAudioDecoded(a, b) + assert.deepEqual(await callItem(alice, aliceContact), {type: "sndCall", status: "progress", duration: 0}) + const connectedAt = Date.now() + + const from = [a.messages.length, b.messages.length] + network.down = true + network.changePath() + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await sleep(3000) + network.down = false + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertAudioDecoded(a, b) + assert.ok(restartSessionsReceived(alice) > 0, "alice received no ICE restart offer") + assert.ok(restartSessionsReceived(bob) > 0, "bob received no ICE restart answer") + assert.equal((await callItem(alice, aliceContact)).status, "progress") + assert.equal((await callItem(bob, bobContact)).status, "progress") + + await alice.cmd(`/_call end @${aliceContact}`) + await a.command({type: "end"}) + await bob.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + const minDuration = Math.floor((Date.now() - connectedAt) / 1000) - 1 + const aliceItem = await callItem(alice, aliceContact) + assert.equal(aliceItem.status, "ended") + assert.ok(aliceItem.duration >= minDuration, `call duration ${aliceItem.duration} < ${minDuration}`) + }) + ) + } + + await t.test("call ends in both chats when reconnection times out", () => + withChatCall({}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + // the first party to time out ends the call, and the other party ends it on "callEnded" event + const endedBy = (peer, other) => peer.waitForEnded(DISCONNECT_DETECTION + 10000).then(() => other) + const other = await Promise.any([endedBy(a, bob), endedBy(b, alice)]) + await other.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + network.down = false + for (const [chat, contactId] of [ + [alice, aliceContact], + [bob, bobContact], + ]) { + const item = await callItem(chat, contactId) + assert.equal(item.status, "ended", `${chat.name} call item: ${JSON.stringify(item)}`) + assert.ok(item.duration >= 5, `${chat.name} call duration ${item.duration}`) + } + }) + ) +}) diff --git a/packages/simplex-chat-webrtc/test/harness.js b/packages/simplex-chat-webrtc/test/harness.js new file mode 100644 index 0000000000..d8e1668ce1 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/harness.js @@ -0,0 +1,317 @@ +// Runs two headless Chromium peers with call.js; the test plays the native app and the chat core. +// Media can only flow through UDP relays controlled by the test, so outages and path changes are simulated +// by dropping packets in the relays. +const http = require("http") +const fs = require("fs") +const path = require("path") +const dgram = require("dgram") +const {chromium} = require("playwright-core") +const LZString = require("lz-string") + +const ROOT = path.join(__dirname, "..") +const PAGE_FILES = { + "/android/call.html": path.join(ROOT, "src/android/call.html"), + "/android/style.css": path.join(ROOT, "src/android/style.css"), + "/call.js": path.join(ROOT, "dist/call.js"), + "/lz-string.min.js": path.join(ROOT, "node_modules/lz-string/libs/lz-string.min.js"), +} +const CONTENT_TYPES = {".html": "text/html", ".js": "text/javascript", ".css": "text/css"} + +const startTime = Date.now() +function log(...args) { + if (process.env.VERBOSE) console.log(((Date.now() - startTime) / 1000).toFixed(1).padStart(6), ...args) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const decode = (s) => JSON.parse(LZString.decompressFromBase64(s)) +const encode = (x) => LZString.compressToBase64(JSON.stringify(x)) + +function servePage() { + const server = http.createServer((req, res) => { + const file = PAGE_FILES[new URL(req.url, "http://localhost").pathname] + if (!file) return res.writeHead(404).end() + res.writeHead(200, {"Content-Type": CONTENT_TYPES[path.extname(file)]}).end(fs.readFileSync(file)) + }) + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))) +} + +function bindUdp(ip) { + return new Promise((resolve) => { + const socket = dgram.createSocket("udp4") + socket.bind(0, ip, () => resolve(socket)) + }) +} + +// Relays one ICE generation: each peer is told that the other peer is at the relay socket. +class Relay { + constructor(network, ip) { + this.network = network + this.ip = ip + this.ufrag = {A: undefined, B: undefined} + this.real = {A: undefined, B: undefined} + this.dead = false + this.forwarded = 0 + } + + async open() { + this.sockets = {A: await bindUdp(this.ip), B: await bindUdp(this.ip)} + this.sockets.A.on("message", (msg, from) => this.forward(msg, from, "B", "A")) + this.sockets.B.on("message", (msg, from) => this.forward(msg, from, "A", "B")) + } + + // socket A is advertised to B as A's address, so packets on it come from B and go to A via socket B + forward(msg, from, sender, receiver) { + if (this.dead || this.network.down) return + this.real[sender] = from + const to = this.real[receiver] + if (!to) return + this.forwarded++ + this.sockets[sender].send(msg, to.port, to.address) + } + + port(side) { + return this.sockets[side].address().port + } + + close() { + this.sockets.A.close() + this.sockets.B.close() + } +} + +class Network { + constructor() { + this.down = false + this.relays = [] + } + + // Returns the rewritten candidate, or undefined if the candidate cannot be relayed. + async route(side, candidate) { + const [, component, protocol, , ip, port, , type] = candidate.candidate.split(" ") + if (protocol.toLowerCase() !== "udp" || type !== "host" || ip.includes(":") || component !== "1") return + const ufrag = candidate.usernameFragment + if (this.relays.some((r) => r.ufrag[side] === ufrag)) return + let relay = this.relays.find((r) => !r.dead && r.ufrag[side] === undefined) + if (!relay) { + relay = new Relay(this, ip) + await relay.open() + this.relays.push(relay) + } + relay.ufrag[side] = ufrag + relay.real[side] = {address: ip, port: +port} + log(`relay ${this.relays.indexOf(relay)}: ${side} ${ip}:${port} -> ${relay.ip}:${relay.port(side)}`) + const parts = candidate.candidate.split(" ") + parts[4] = relay.ip + parts[5] = String(relay.port(side)) + return {...candidate, candidate: parts.join(" ")} + } + + // Takes and returns compressed session description and candidates, as in signaling messages + async routeSignal(side, session, iceCandidates) { + // candidates in SDP would connect the peers directly, bypassing the relays + if (session && decode(session).sdp.includes("a=candidate:")) throw new Error(`${side}: SDP has ICE candidates`) + const routed = [] + for (const c of decode(iceCandidates)) { + const r = c?.candidate && (await this.route(side, c)) + if (r) routed.push(r) + } + return {session, iceCandidates: encode(routed)} + } + + // Addresses of all current ICE generations become unreachable, as when a device switches networks. + changePath() { + for (const relay of this.relays) relay.dead = true + } + + close() { + for (const relay of this.relays) relay.close() + } +} + +class Peer { + constructor(name, page, {ignoresIceRestart = false} = {}) { + this.name = name + this.page = page + this.ignoresIceRestart = ignoresIceRestart + this.messages = [] + this.waiters = [] + this.onResponse = async () => {} + } + + async open(url) { + await this.page.exposeFunction("sendToTest", (json) => this.receive(JSON.parse(json))) + this.page.on("console", (m) => log(`${this.name} console:`, m.text().slice(0, 200))) + this.page.on("pageerror", (e) => log(`${this.name} page error:`, e.message)) + await this.page.goto(url) + await this.page.evaluate(() => { + sendMessageToNative = (msg) => window.sendToTest(JSON.stringify(msg)) + }) + } + + receive(msg) { + const resp = msg.resp + log(`${this.name} <-`, resp.type, resp.state?.connectionState ?? "", resp.session ? "with session" : "") + this.messages.push(msg) + if (!msg.command) this.onResponse(resp).catch((e) => log(`${this.name} response error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(msg) && (w.resolve(msg), true))) + } + + connectionStates() { + return this.messages.filter((m) => m.resp.type === "connection").map((m) => m.resp.state.connectionState) + } + + sessionsSent() { + return this.messages.filter((m) => m.resp.type === "ice" && !m.command && m.resp.session).length + } + + ended() { + return this.messages.some((m) => m.resp.type === "ended") + } + + waitFor(pred, timeout, what) { + const found = this.messages.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (msg) => { + clearTimeout(timer) + resolve(msg) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} in ${timeout}ms, connection states: ${this.connectionStates()}`)) + }, timeout) + }) + } + + waitForState(state, timeout, after = 0) { + const pred = (m) => m.resp.type === "connection" && m.resp.state.connectionState === state && this.messages.indexOf(m) >= after + return this.waitFor(pred, timeout, `"${state}" state`) + } + + waitForEnded(timeout) { + return this.waitFor((m) => m.resp.type === "ended", timeout, "ended") + } + + command(command) { + log(`${this.name} ->`, command.type, command.session ? "with session" : "") + return this.page.evaluate((c) => processCommand({corrId: 0, command: c}), command) + } + + async setTimeouts(timeouts) { + await this.page.evaluate((t) => { + if (t.reconnectTimeout) reconnectTimeout = t.reconnectTimeout + if (t.iceRestartInterval) iceRestartInterval = t.iceRestartInterval + }, timeouts) + } + + async receivedBytes() { + return (await this.inboundAudio()).bytesReceived + } + + // energy grows only when received audio is decoded, i.e. it is decrypted with the right key + async receivedAudioEnergy() { + return (await this.inboundAudio()).totalAudioEnergy + } + + inboundAudio() { + return this.page.evaluate(async () => { + let audio + ;(await activeCall.connection.getStats()).forEach((s) => { + if (s.type === "inbound-rtp" && s.kind === "audio" && (!audio || s.bytesReceived > audio.bytesReceived)) audio = s + }) + return {bytesReceived: audio?.bytesReceived ?? 0, totalAudioEnergy: audio?.totalAudioEnergy ?? 0} + }) + } +} + +// Signaling stops while the network is down and resumes in order, as messages queued in SMP servers. +class Signaling { + constructor(network) { + this.network = network + this.queue = Promise.resolve() + this.closed = false + } + + send(deliver) { + this.queue = this.queue.then(async () => { + while (this.network.down && !this.closed) await sleep(100) + if (!this.closed) await deliver().catch((e) => log("signaling error", e.message)) + }) + } +} + +// useWorker encrypts media in a worker with RTCRtpScriptTransform, as the desktop app does +async function openPeers({answerer = {}, useWorker = false} = {}) { + const server = await servePage() + const url = `http://localhost:${server.address().port}/android/call.html` + const browser = await chromium.launch({ + executablePath: process.env.CHROME, + args: [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--disable-features=WebRtcHideLocalIpsWithMdns", + "--autoplay-policy=no-user-gesture-required", + ], + }) + const newPeer = async (name, opts) => { + const context = await browser.newContext({permissions: ["camera", "microphone"]}) + const peer = new Peer(name, await context.newPage(), opts) + 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 b = await newPeer("B") + const network = new Network() + const signaling = new Signaling(network) + const close = async () => { + signaling.closed = true + await browser.close() + network.close() + server.close() + } + return {a, b, network, signaling, close} +} + +// 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}) + 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})) + } + 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: []}) + 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({ + type: "offer", + offer: offerSignal.session, + iceCandidates: offerSignal.iceCandidates, + media, + iceServers: [], + }) + 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) + const ok = await b.command({type: "answer", answer: answerSignal.session, iceCandidates: answerSignal.iceCandidates}) + if (ok.resp.type !== "ok") throw new Error(`B answer: ${JSON.stringify(ok.resp)}`) + await Promise.all([a.waitForState("connected", 20000), b.waitForState("connected", 20000)]) + } catch (e) { + await close() + throw e + } + return peers +} + +module.exports = {openPeers, startCall, sleep, log} diff --git a/packages/simplex-chat-webrtc/test/reconnect.test.js b/packages/simplex-chat-webrtc/test/reconnect.test.js new file mode 100644 index 0000000000..d7eb9f861e --- /dev/null +++ b/packages/simplex-chat-webrtc/test/reconnect.test.js @@ -0,0 +1,95 @@ +const {test} = require("node:test") +const assert = require("node:assert/strict") +const {startCall, sleep} = require("./harness") + +// Chrome reports "disconnected" about 6 seconds after packets stop +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 + +async function assertMediaFlows(a, b) { + const before = [await a.receivedBytes(), await b.receivedBytes()] + await sleep(2000) + const after = [await a.receivedBytes(), await b.receivedBytes()] + assert.ok(after[0] > before[0], `A received no media: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B received no media: ${before[1]} -> ${after[1]}`) +} + +async function assertReconnects(a, b, outage) { + const from = [a.messages.length, b.messages.length] + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await outage() + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertMediaFlows(a, b) + assert.ok(!a.ended() && !b.ended(), "call ended") + assert.ok(!a.connectionStates().includes("disconnected"), `A states: ${a.connectionStates()}`) + assert.ok(!b.connectionStates().includes("disconnected"), `B states: ${b.connectionStates()}`) +} + +async function withCall(opts, run) { + const call = await startCall(opts) + try { + await run(call) + } finally { + await call.close() + } +} + +test("call reconnection", {concurrency: true}, async (t) => { + await t.test("recovers after a brief outage", () => + withCall({}, async ({a, b, network}) => { + await assertMediaFlows(a, b) + network.down = true + await assertReconnects(a, b, async () => { + network.down = false + }) + }) + ) + + await t.test("recovers after an outage longer than ICE consent timeout", () => + withCall({}, async ({a, b, network}) => { + network.down = true + await assertReconnects(a, b, async () => { + await sleep(15000) + network.down = false + }) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.ok(a.sessionsSent() > 0, "answerer did not answer ICE restart") + }) + ) + + await t.test("recovers when the network path changes", () => + withCall({}, async ({a, b, network}) => { + network.down = true + network.changePath() + await assertReconnects(a, b, async () => { + network.down = false + }) + const relay = network.relays[network.relays.length - 1] + assert.ok(!relay.dead && relay.forwarded > 0, "media does not use the new path") + }) + ) + + await t.test("ends the call when reconnection times out", () => + withCall({}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + await Promise.all([a.waitForState("reconnecting", DISCONNECT_DETECTION), b.waitForState("reconnecting", DISCONNECT_DETECTION)]) + await Promise.all([a.waitForEnded(10000), b.waitForEnded(10000)]) + assert.deepEqual(a.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + assert.deepEqual(b.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + }) + ) + + 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) + }) + ) +})