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:
authored and GitHub committed 2026-10-19 12:17:16 +01:00
1 parent 634fa5ecff
commit 2ea1ec0d50
27 files changed
+1247 -136

No files matched your search

+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: ()
}