mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 21:33:47 +00:00
* 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>
189 lines
8.7 KiB
JavaScript
189 lines
8.7 KiB
JavaScript
// 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}`)
|
|
}
|
|
})
|
|
)
|
|
})
|