Files
securebit-chat/tests/group-call-autoanswer.test.mjs
T
lockbitchat 113bb107d3
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s
feat(groups): audio and video calls in group chats; release v6.6.6
A group call is N-1 ordinary 1:1 calls, one to each other member, each riding
the pairwise session that member already has — a transport a human already
authenticated by comparing the safety code. No mixer, no SFU, no point at which
two people's media meets anywhere but on a device.

Call control is separate from call media, because the two reach different sets
of people. Who opened a call, who joined and who left travels as group frames
signed with the sender's group identity key, so it reaches members currently
reachable only through a relay — and a relaying member can drop one but cannot
write one. Media flows only where a direct link exists, so a member without one
shows as connecting rather than being omitted. Frames carry a per-sender
sequence checked before the action, so a captured leave cannot end a later call,
and simultaneous calls converge on the lower random call id.

One capture is shared across every leg rather than one getUserMedia per member,
and legs answer without prompting: the flag permitting that is set only locally,
only while this user is in the call, and cleared when they leave.

UI: a gallery that sizes itself from the space it has, a spotlight view, an
active-speaker indicator read from the waveform, and the call surface in the
same visual language as the 1:1 one.

Also in this commit, the v6.5.0 language-suggestion work that had not been
pushed yet; its notes are in the changelog. And two fixes: the safety-code input
asks for digits rather than text, and starting a new chat from inside a group no
longer creates it behind the group where it cannot be seen — which had made it
impossible to connect to anyone new, or to add anyone to a group, while a group
was open.

Claude-Session: https://claude.ai/code/session_01XSxAkET3hQTkYDQfbjCQwZ
2026-09-01 01:04:11 -04:00

98 lines
4.3 KiB
JavaScript

// One leg of a group call must never sit there ringing.
//
// A group call is N-1 ordinary 1:1 calls, and each of them is answered without
// prompting because the user already consented once, by joining. That answer is
// decided when the offer LANDS — so an offer that arrives before this session
// has been told it is a call leg falls through the check and rings instead.
//
// The window is real. A member who joins can place their offers immediately,
// and those offers can overtake the group frame announcing that they joined. The
// symptom was asymmetric and confusing: one member's tile read "connecting" for
// the whole call while the other member's browser was quietly ringing for a call
// they had already agreed to be in.
//
// The ordering that opens the window is fixed elsewhere (the app announces
// before it connects). This covers the safety net underneath it: being told,
// late, that a session is a call leg has to pick up whatever is already ringing.
import assert from 'node:assert/strict';
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
/** A manager with only the call-state machinery a leg touches. */
function callLeg(phase, { pending = null } = {}) {
const leg = Object.create(EnhancedSecureWebRTCManager.prototype);
leg.callState = {
active: phase !== 'idle', phase, withVideo: false, micEnabled: true,
cameraEnabled: false, remoteHasVideo: false, callId: 'c1', quality: null,
groupCallId: null, error: null,
};
leg._callGroupContext = null;
leg._callStateListeners = new Set();
leg._pendingCallOffer = pending;
leg.onCallStateChanged = null;
leg.accepted = 0;
leg.acceptCall = async function () { this.accepted += 1; };
leg._startAdaptation = () => {};
leg._stopAdaptation = () => {};
leg._secureLog = () => {};
return leg;
}
const offer = { sdp: 'v=0', callId: 'c1', withVideo: false };
// ── an offer that was already ringing is answered when the leg is claimed ────
{
const leg = callLeg('incoming', { pending: offer });
leg.setCallGroupContext('group-call-1');
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 1, 'a ringing group leg must be answered as soon as it is claimed');
assert.equal(leg.getCallState().groupCallId, 'group-call-1',
'and the state must say which group call it belongs to, so the 1:1 UI stays out of the way');
}
// ── an idle session is left alone: there is nothing to answer ────────────────
{
const leg = callLeg('idle');
leg.setCallGroupContext('group-call-1');
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 0, 'claiming an idle session must not invent a call');
}
// ── a call already up is not answered a second time ─────────────────────────
{
const leg = callLeg('active', { pending: offer });
leg.setCallGroupContext('group-call-1');
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 0, 'a live call must not be re-answered');
}
// ── THE guard: releasing a leg must never auto-answer anything ───────────────
//
// This is the part that would be a security bug rather than a display one. The
// flag is what allows a microphone to open without asking, so clearing it — which
// is what leaving a call does — must not be a path to opening one.
{
const leg = callLeg('incoming', { pending: offer });
leg._callGroupContext = 'group-call-1';
leg.setCallGroupContext(null);
await new Promise((r) => setTimeout(r, 0));
assert.equal(leg.accepted, 0, 'clearing the group context must never answer a call');
assert.equal(leg.getCallState().groupCallId, null);
}
// ── and a session that is no longer a leg rings normally again ──────────────
{
const leg = callLeg('idle');
leg.setCallGroupContext('group-call-1');
leg.setCallGroupContext(null);
assert.equal(leg._callGroupContext, null,
'a cleared context must not linger — it is what lets a call open a microphone unasked');
}
console.log('group-call-autoanswer: ok');