ui: reconnect call (#7711)

* Update Call.hs

* Update Internal.hs

* add reconnecting string

* Update WebRTC.kt

* desktop: show reconnecting state, keep call duration

* android keep connection time correct wiith adding reconnecting

* Update CallTypes.swift add reconnecting status on IOS

* Update WebRTC.swift add reconnecting

* add reconnecting and make sure call duration is correct

* Update call.ts

* core: regenerate call.js from call.ts

* Update Call.hs revert call status change

minimization

* Update Internal.hs rever to minimize changes

* Update CallTypes.swift minimize changes

* Update WebRTC.kt minimize changes

* Update CallView.desktop.kt desktop shows reconnecting without sending to core

* Update CallView.android.kt show reconnecting without sending it to the core

* Update ActiveCallView.swift show reconnecting to ui without sending to core

* Update CallView.desktop.kt add reconnecting sound on reconnect

* Update CallView.android.kt add reconnecting sound

* Update ActiveCallView.swift play reconnecting sound when reconnecting

* Update RecAndPlay.desktop.kt

* rebuild

* refactor

* build

* android, desktop: restart ICE to reconnect calls

* ios: restart ICE to reconnect calls

* webrtc: add call reconnection e2e tests

* android, desktop: fix lost call commands

---------

Co-authored-by: GuavaGoat <320862384+GuavaGoat@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
sh
2026-10-19 12:17:16 +01:00
committed by GitHub
co-authored by GuavaGoat Evgeny Poberezkin Evgeny @ SimpleX Chat
parent 634fa5ecff
commit 2ea1ec0d50
27 changed files with 1247 additions and 136 deletions
+3 -3
View File
@@ -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) }) {
+26 -12
View File
@@ -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
+14 -6
View File
@@ -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)
+135 -28
View File
@@ -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<Void, Never>? = nil
var iceRestarts: Task<Void, Never>? = 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: ()
}
+3 -1
View File
@@ -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
}
+10 -2
View File
@@ -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 |
+1 -1
View File
@@ -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) |
+12 -4
View File
@@ -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
@@ -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<WebRTCCallStatus>("\"${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<WCallCommand>, 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()
@@ -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 -> {
@@ -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<RTCIceServer>? = 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<RTCIceServer>? = 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?,
@@ -1301,6 +1301,7 @@
<string name="callstate_received_answer">received answer…</string>
<string name="callstate_received_confirmation">received confirmation…</string>
<string name="callstate_connecting">connecting…</string>
<string name="callstate_reconnecting">reconnecting…</string>
<string name="callstate_connected">connected</string>
<string name="callstate_ended">ended</string>
@@ -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)
@@ -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
@@ -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) {
@@ -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<WebRTCCallStatus>("\"${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<WCallCommand>, 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)
}
}
}
}
}
+11 -1
View File
@@ -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 |
+5 -4
View File
@@ -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) |
+1 -1
View File
@@ -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) |
+4 -1
View File
@@ -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.
---
<a id="ActiveCallView"></a>
@@ -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 |
+17
View File
@@ -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
```
+2 -1
View File
@@ -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"
},
+88 -17
View File
@@ -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<void>
}
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<string, any>
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<T>(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 {
+147
View File
@@ -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}
@@ -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}`)
}
})
)
})
@@ -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}
@@ -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)
})
)
})