ui: don't reconnect calls with peers before v22 (#7785)

This commit is contained in:
sh authored and GitHub committed 2026-10-23 10:06:33 +01:00
1 parent 773434a561
commit 31c4b68189
16 files changed
+64 -40

No files matched your search

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