From 45860644105cdba76037f690bafd0e4bae3647bb Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 5 May 2026 13:46:51 +0200 Subject: [PATCH] Calls: Upgrade library (#6916) --- eslint.config.js | 1 - src/api/gramjs/apiBuilders/calls.ts | 29 +- src/api/gramjs/methods/calls.ts | 45 +- src/api/gramjs/methods/phoneCallState.ts | 194 +- src/api/gramjs/methods/sctpSignaling.ts | 640 +++++ src/api/gramjs/updates/mtpUpdateHandler.ts | 2 +- src/api/types/calls.ts | 8 +- src/api/types/updates.ts | 2 +- src/components/calls/group/GroupCall.tsx | 4 +- .../calls/group/GroupCallParticipant.tsx | 4 +- .../calls/group/GroupCallParticipantList.tsx | 2 +- .../calls/group/GroupCallParticipantMenu.tsx | 2 +- .../calls/group/GroupCallParticipantVideo.tsx | 4 +- .../calls/group/MicrophoneButton.tsx | 2 +- .../calls/group/OutlinedMicrophoneIcon.tsx | 4 +- .../group/helpers/formatGroupCallVolume.ts | 2 +- .../calls/phone/PhoneCall.module.scss | 4 + src/components/calls/phone/PhoneCall.tsx | 63 +- src/config.ts | 3 + src/global/actions/api/calls.async.ts | 84 +- src/global/actions/apiUpdaters/calls.async.ts | 292 ++- src/global/actions/apiUpdaters/calls.ts | 2 +- src/global/actions/ui/calls.ts | 1 + src/global/reducers/calls.ts | 2 +- src/global/types/actions.ts | 2 +- src/lib/gramjs/network/MTProtoSender.ts | 8 +- src/lib/gramjs/tl/apiTl.ts | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/lib/secret-sauce/p2p.ts | 506 ---- src/lib/secret-sauce/parseSdp.ts | 140 -- .../fallbackMedia.ts} | 0 .../group/dataChannelMessages.ts} | 2 +- .../group/groupCall.ts} | 640 ++++- src/lib/{secret-sauce => vibecalls}/index.ts | 9 +- src/lib/vibecalls/phone/phoneCall.ts | 2195 +++++++++++++++++ .../phone/signalingMessages.ts} | 30 +- .../sdp}/buildSdp.ts | 19 +- src/lib/vibecalls/sdp/common.ts | 231 ++ src/lib/vibecalls/sdp/groupSdp.ts | 127 + src/lib/{secret-sauce => vibecalls}/types.ts | 6 +- src/lib/{secret-sauce => vibecalls}/utils.ts | 13 +- src/styles/_mixins.scss | 12 - src/util/browser/windowEnvironment.ts | 2 +- src/util/primitives/primitiveRecord.ts | 13 + webpack.config.ts | 6 - 45 files changed, 4421 insertions(+), 938 deletions(-) create mode 100644 src/api/gramjs/methods/sctpSignaling.ts delete mode 100644 src/lib/secret-sauce/p2p.ts delete mode 100644 src/lib/secret-sauce/parseSdp.ts rename src/lib/{secret-sauce/blacksilence.ts => vibecalls/fallbackMedia.ts} (100%) rename src/lib/{secret-sauce/colibriClass.ts => vibecalls/group/dataChannelMessages.ts} (96%) rename src/lib/{secret-sauce/secretsauce.ts => vibecalls/group/groupCall.ts} (54%) rename src/lib/{secret-sauce => vibecalls}/index.ts (65%) create mode 100644 src/lib/vibecalls/phone/phoneCall.ts rename src/lib/{secret-sauce/p2pMessage.ts => vibecalls/phone/signalingMessages.ts} (65%) rename src/lib/{secret-sauce => vibecalls/sdp}/buildSdp.ts (91%) create mode 100644 src/lib/vibecalls/sdp/common.ts create mode 100644 src/lib/vibecalls/sdp/groupSdp.ts rename src/lib/{secret-sauce => vibecalls}/types.ts (95%) rename src/lib/{secret-sauce => vibecalls}/utils.ts (87%) create mode 100644 src/util/primitives/primitiveRecord.ts diff --git a/eslint.config.js b/eslint.config.js index 6facc4f9b..610994dc4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,7 +32,6 @@ export default defineConfig( 'src/lib/gramjs/tl/', 'src/lib/lovely-chart/**', 'src/lib/music-metadata-browser', - 'src/lib/secret-sauce/', 'src/lib/fastBlur.js', 'src/types/language.d.ts', 'dist/', diff --git a/src/api/gramjs/apiBuilders/calls.ts b/src/api/gramjs/apiBuilders/calls.ts index c792b58d6..9a04bf4cc 100644 --- a/src/api/gramjs/apiBuilders/calls.ts +++ b/src/api/gramjs/apiBuilders/calls.ts @@ -6,11 +6,30 @@ import type { GroupCallParticipant, GroupCallParticipantVideo, SsrcGroup, -} from '../../../lib/secret-sauce'; +} from '../../../lib/vibecalls'; import type { ApiGroupCall, ApiPhoneCall } from '../../types'; +import { CALL_PROTOCOL_LIBRARY_VERSIONS } from '../../../config'; +import { sanitizePrimitiveRecord } from '../../../util/primitives/primitiveRecord'; import { getApiChatIdFromMtpPeer, isMtpPeerUser } from './peers'; +function parseCallParameters(data?: GramJs.TypeDataJSON) { + if (!data?.data) { + return undefined; + } + + try { + const parsed = JSON.parse(data.data); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + + return sanitizePrimitiveRecord(parsed as Record); + } catch { + return undefined; + } +} + export function buildApiGroupCallParticipant(participant: GramJs.GroupCallParticipant): GroupCallParticipant { const { self, min, about, date, versioned, canSelfUnmute, justJoined, left, muted, mutedByYou, source, volume, @@ -134,7 +153,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall { if (call instanceof GramJs.PhoneCall) { const { - p2pAllowed, gAOrB, keyFingerprint, connections, startDate, + p2pAllowed, gAOrB, keyFingerprint, connections, startDate, customParameters, } = call; phoneCall = { @@ -145,6 +164,7 @@ export function buildPhoneCall(call: GramJs.TypePhoneCall): ApiPhoneCall { startDate, isP2pAllowed: Boolean(p2pAllowed), connections: connections.map(buildApiCallConnection).filter(Boolean), + customParameters: parseCallParameters(customParameters), }; } @@ -234,8 +254,9 @@ export function buildApiCallProtocol(protocol: GramJs.PhoneCallProtocol): ApiCal export function buildCallProtocol() { return new GramJs.PhoneCallProtocol({ - libraryVersions: ['4.0.0'], - minLayer: 92, + libraryVersions: CALL_PROTOCOL_LIBRARY_VERSIONS, + // Hardcoded values according to the docs + minLayer: 65, maxLayer: 92, udpReflector: true, udpP2p: true, diff --git a/src/api/gramjs/methods/calls.ts b/src/api/gramjs/methods/calls.ts index 3eea93dc8..840435fd6 100644 --- a/src/api/gramjs/methods/calls.ts +++ b/src/api/gramjs/methods/calls.ts @@ -1,9 +1,9 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { generateRandomInt32 } from '../../../lib/gramjs/Helpers'; -import type { JoinGroupCallPayload } from '../../../lib/secret-sauce'; +import type { JoinGroupCallPayload } from '../../../lib/vibecalls'; import type { - ApiChat, ApiGroupCall, ApiPhoneCall, ApiUser, + ApiChat, ApiGroupCall, ApiPeer, ApiPhoneCall, ApiUser, } from '../../types'; import { GROUP_CALL_PARTICIPANTS_LIMIT } from '../../../limits'; @@ -18,6 +18,14 @@ import { import { sendApiUpdate } from '../updates/apiUpdateEmitter'; import { invokeRequest, invokeRequestBeacon } from './client'; +const MAX_SIGNED_INT64 = (1n << 63n) - 1n; +const UINT64_MOD = 1n << 64n; + +function buildSignedLong(value: string) { + const parsed = BigInt(value); + return parsed > MAX_SIGNED_INT64 ? parsed - UINT64_MOD : parsed; +} + export async function getGroupCall({ call, }: { @@ -127,13 +135,13 @@ export async function fetchGroupCallParticipants({ } export function leaveGroupCall({ - call, isPageUnload, + call, isPageUnload, source, }: { - call: ApiGroupCall; isPageUnload?: boolean; + call: ApiGroupCall; isPageUnload?: boolean; source?: number; }) { const request = new GramJs.phone.LeaveGroupCall({ call: buildInputGroupCall(call), - source: DEFAULT_PRIMITIVES.INT, + source: source ?? DEFAULT_PRIMITIVES.INT, }); if (isPageUnload) { @@ -141,19 +149,19 @@ export function leaveGroupCall({ return; } - invokeRequest(request, { + return invokeRequest(request, { shouldReturnTrue: true, }); } export async function joinGroupCall({ - call, inviteHash, params, + call, inviteHash, params, joinAs, }: { - call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; + call: ApiGroupCall; inviteHash?: string; params: JoinGroupCallPayload; joinAs?: ApiPeer; }) { const result = await invokeRequest(new GramJs.phone.JoinGroupCall({ call: buildInputGroupCall(call), - joinAs: new GramJs.InputPeerSelf(), + joinAs: joinAs ? buildInputPeer(joinAs.id, joinAs.accessHash) : new GramJs.InputPeerSelf(), muted: true, videoStopped: true, params: new GramJs.DataJSON({ @@ -287,7 +295,7 @@ export async function requestCall({ randomId: generateRandomInt32(), userId: buildInputUser(user.id, user.accessHash), gAHash: Buffer.from(gAHash), - ...(isVideo && { video: true }), + video: isVideo ? true : undefined, protocol: buildCallProtocol(), })); @@ -362,7 +370,7 @@ export async function confirmCall({ const result = await invokeRequest(new GramJs.phone.ConfirmCall({ peer: buildInputPhoneCall(call), gA: Buffer.from(gA), - keyFingerprint: BigInt(keyFingerprint), + keyFingerprint: buildSignedLong(keyFingerprint), protocol: buildCallProtocol(), })); @@ -390,3 +398,18 @@ export function sendSignalingData({ peer: buildInputPhoneCall(call), })); } + +export async function fetchCallConfig() { + const result = await invokeRequest(new GramJs.phone.GetCallConfig()); + if (!result) { + return undefined; + } + + try { + const parsed = JSON.parse(result.data); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record : undefined; + } catch { + return undefined; + } +} diff --git a/src/api/gramjs/methods/phoneCallState.ts b/src/api/gramjs/methods/phoneCallState.ts index 926ce375a..991ba5d08 100644 --- a/src/api/gramjs/methods/phoneCallState.ts +++ b/src/api/gramjs/methods/phoneCallState.ts @@ -1,9 +1,10 @@ -import { AuthKey } from '../../../lib/gramjs/crypto/AuthKey'; -import { Logger } from '../../../lib/gramjs/extensions'; +import { gunzipSync, gzipSync } from 'fflate'; +import { CTR } from '../../../lib/gramjs/crypto/CTR'; import { - convertToLittle, getByteArray, modExp, readBigIntFromBuffer, sha1, sha256, + getByteArray, modExp, readBigIntFromBuffer, readBufferFromBigInt, sha1, sha256, } from '../../../lib/gramjs/Helpers'; -import MTProtoState from '../../../lib/gramjs/network/MTProtoState'; + +import { isSctpPacket, SctpSignaling } from './sctpSignaling'; type DhConfig = { p: number[]; @@ -14,10 +15,16 @@ type DhConfig = { let currentPhoneCallState: PhoneCallState | undefined; class PhoneCallState { - private state?: MTProtoState; + private authKey?: Buffer; + + private sctp = new SctpSignaling(); private seq = 0; + private maxInboundSeq = 0; + + private inboundSeqs = new Set(); + private gA?: bigint; private gB?: bigint; @@ -30,14 +37,27 @@ class PhoneCallState { private resolveState?: VoidFunction; + private isDestroyed = false; + constructor( private isOutgoing: boolean, + private shouldUseSctp = true, ) { this.waitForState = new Promise((resolve) => { this.resolveState = resolve; }); } + destroy() { + this.isDestroyed = true; + this.resolveState?.(); + this.resolveState = undefined; + } + + setShouldUseSctp(shouldUseSctp: boolean) { + this.shouldUseSctp = shouldUseSctp; + } + async requestCall({ p, g, random }: DhConfig) { const pBN = readBigIntFromBuffer(Buffer.from(p), false); const randomBN = readBigIntFromBuffer(Buffer.from(random), false); @@ -80,7 +100,7 @@ class PhoneCallState { this.p, ); const fingerprint: Buffer = await sha1(getByteArray(authKey)); - const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8).reverse(), false); + const keyFingerprint = readBigIntFromBuffer(fingerprint.slice(-8), true, true); const emojis = await generateEmojiFingerprint( getByteArray(authKey), @@ -89,35 +109,142 @@ class PhoneCallState { emojiOffsets, ); - const key = new AuthKey(); - await key.setKey(getByteArray(authKey)); - this.state = new MTProtoState(key, new Logger(), true, this.isOutgoing); - this.resolveState!(); + this.authKey = readBufferFromBigInt(authKey, 256, false); + this.resolveState?.(); + this.resolveState = undefined; return { gA: Array.from(getByteArray(this.gA!)), keyFingerprint: keyFingerprint.toString(), emojis }; } - async encode(data: string) { - if (!this.state) return undefined; + private async calcKey(msgKey: Buffer, isClient: boolean) { + if (!this.authKey) { + throw new Error('Auth key unset'); + } - const seqArray = new Uint32Array(1); - seqArray[0] = this.seq++; - const encodedData = await this.state.encryptMessageData( - Buffer.concat([convertToLittle(seqArray), Buffer.from(data)]), - ); - return Array.from(encodedData); + const x = 128 + (this.isOutgoing !== isClient ? 8 : 0); + const [sha256a, sha256b] = await Promise.all([ + sha256(Buffer.concat([msgKey, this.authKey.slice(x, x + 36)])), + sha256(Buffer.concat([this.authKey.slice(x + 40, x + 76), msgKey])), + ]); + + return { + key: Buffer.concat([sha256a.slice(0, 8), sha256b.slice(8, 24), sha256a.slice(24, 32)]), + iv: Buffer.concat([sha256b.slice(0, 4), sha256a.slice(8, 16), sha256b.slice(24, 28)]), + }; + } + + async encode(data: unknown) { + if (!this.authKey) return undefined; + + const message = Buffer.from(gzipSync(Buffer.from(JSON.stringify(data)))); + const packet = Buffer.alloc(4 + message.length); + packet.writeUInt32BE(++this.seq, 0); + message.copy(packet, 4); + + const x = 128 + (this.isOutgoing ? 0 : 8); + const msgKeyLarge = await sha256(Buffer.concat([this.authKey.slice(88 + x, 88 + x + 32), packet])); + const msgKey = msgKeyLarge.slice(8, 24); + const { key, iv } = await this.calcKey(msgKey, true); + const encrypted = new CTR(key, iv).encrypt(packet); + const body = Buffer.concat([msgKey, encrypted]); + + return this.shouldUseSctp ? this.sctp.wrapPayload(body) : Array.from(body); } async decode(data: number[]): Promise { - if (!this.state) { - return this.waitForState.then(() => { - return this.decode(data); - }); + if (this.isDestroyed) { + return undefined; } - const message = await this.state.decryptMessageData(Buffer.from(data)) as Buffer; + if (!this.authKey) { + await this.waitForState; + if (this.isDestroyed || !this.authKey) { + return undefined; + } + return this.decode(data); + } - return JSON.parse(message.toString()); + const incoming = Buffer.from(data); + const payloads = isSctpPacket(incoming) ? this.sctp.receive(incoming) : []; + const bodies = payloads.length ? payloads : [incoming]; + const messages = []; + for (const body of bodies) { + const message = await this.decodeBody(body); + if (message) { + messages.push(message); + } + } + + if (messages.length > 1) { + return messages; + } + + return messages[0]; + } + + private async decodeBody(body: Buffer): Promise { + if (body.length < 21) { + return undefined; + } + const authKey = this.authKey; + if (!authKey) { + return undefined; + } + + const msgKey = body.slice(0, 16); + const encryptedData = body.slice(16); + const { key, iv } = await this.calcKey(msgKey, false); + const decrypted = new CTR(key, iv).decrypt(encryptedData); + + const x = 128 + (this.isOutgoing ? 8 : 0); + const msgKeyLarge = await sha256(Buffer.concat([authKey.slice(88 + x, 88 + x + 32), decrypted])); + if (!msgKey.equals(msgKeyLarge.slice(8, 24))) { + return undefined; + } + + if (decrypted.length < 4) { + return undefined; + } + + const inboundSeq = decrypted.readUInt32BE(0); + if (!this.shouldAcceptInboundSeq(inboundSeq)) { + return undefined; + } + + const message = decrypted.slice(4); + try { + const payload = message[0] === 0x1F && message[1] === 0x8B ? Buffer.from(gunzipSync(message)) : message; + this.markInboundSeq(inboundSeq); + return JSON.parse(payload.toString()); + } catch { + return undefined; + } + } + + private shouldAcceptInboundSeq(seq: number) { + return Boolean(seq && seq > this.maxInboundSeq - 64 && !this.inboundSeqs.has(seq)); + } + + private markInboundSeq(seq: number) { + this.inboundSeqs.add(seq); + if (seq > this.maxInboundSeq) { + this.maxInboundSeq = seq; + } + + const minSeq = this.maxInboundSeq - 64; + this.inboundSeqs.forEach((item) => { + if (item <= minSeq) { + this.inboundSeqs.delete(item); + } + }); + } + + drainSignalingData() { + if (!this.shouldUseSctp) { + return []; + } + + return this.sctp.drainPackets(); } } @@ -150,11 +277,22 @@ async function generateEmojiFingerprint( return result.join(''); } -export function createPhoneCallState(params: ConstructorParameters) { - currentPhoneCallState = new PhoneCallState(...params); +export function createPhoneCallState({ + isOutgoing, + shouldUseSctp = true, +}: { + isOutgoing: boolean; + shouldUseSctp?: boolean; +}) { + currentPhoneCallState = new PhoneCallState(isOutgoing, shouldUseSctp); +} + +export function setPhoneCallSctpEnabled(shouldUseSctp: boolean) { + currentPhoneCallState?.setShouldUseSctp(shouldUseSctp); } export function destroyPhoneCallState() { + currentPhoneCallState?.destroy(); currentPhoneCallState = undefined; } @@ -179,6 +317,10 @@ export async function decodePhoneCallData(params: ParamsOf<'decode'>) { return result; } +export function drainPhoneCallSignalingData() { + return currentPhoneCallState?.drainSignalingData() || []; +} + export function confirmPhoneCall(params: ParamsOf<'confirmCall'>): ReturnTypeOf<'confirmCall'> { return currentPhoneCallState!.confirmCall(...params); } diff --git a/src/api/gramjs/methods/sctpSignaling.ts b/src/api/gramjs/methods/sctpSignaling.ts new file mode 100644 index 000000000..66a15ccb1 --- /dev/null +++ b/src/api/gramjs/methods/sctpSignaling.ts @@ -0,0 +1,640 @@ +import { DEBUG_CALLS } from '../../../config'; + +type SctpChunk = { + type: number; + flags: number; + body: Buffer; +}; + +type SctpDataChunk = { + flags: number; + body: Buffer; +}; + +const SCTP_PORT = 5000; +const SCTP_RECEIVE_WINDOW = 0x500000; +const SCTP_MAX_PENDING_PEER_DATA_CHUNKS = 256; +const SCTP_MAX_PENDING_PEER_DATA_BYTES = 0x100000; +const SCTP_MAX_PENDING_PEER_TSN_GAP = 1024; +const SCTP_INIT_RETRY_DELAY = 1000; +const SCTP_MAX_INIT_RETRY_DELAY = 8000; +const SCTP_STREAM_ID = 0; +const SCTP_BINARY_PPID = 53; +const SCTP_INIT = 1; +const SCTP_INIT_ACK = 2; +const SCTP_SACK = 3; +const SCTP_HEARTBEAT = 4; +const SCTP_HEARTBEAT_ACK = 5; +const SCTP_ABORT = 6; +const SCTP_STATE_COOKIE = 7; +const SCTP_COOKIE_ECHO = 10; +const SCTP_COOKIE_ACK = 11; +const SCTP_DATA = 0; + +const CRC32C_TABLE = createCrc32cTable(); + +export class SctpSignaling { + private localTag = generateUint32(); + + private localTsn = generateUint32(); + + private localSsn = 0; + + private peerTag?: number; + + private peerInitialTsn?: number; + + private peerCumulativeTsn?: number; + + private initSent = false; + + private initSentAt = 0; + + private initRetryCount = 0; + + private isEstablished = false; + + private cookie = Buffer.alloc(0); + + private pendingPayloads: Buffer[] = []; + + private pendingPackets: number[][] = []; + + private pendingPeerData = new Map(); + + private pendingPeerDataSize = 0; + + private reassembly?: Buffer[]; + + wrapPayload(payload: Buffer) { + if (this.isEstablished && this.peerTag !== undefined) { + return Array.from(this.createDataPacket(payload)); + } + + this.pendingPayloads.push(payload); + if (!this.initSent) { + return this.createInitRetryPacket(); + } + + if (this.shouldRetryInit()) { + logSctp('INIT retrying', { + retryCount: this.initRetryCount, + pendingPayloadCount: this.pendingPayloads.length, + }); + return this.createInitRetryPacket(); + } + + return undefined; + } + + private shouldRetryInit() { + if (!this.initSentAt || this.peerTag !== undefined) { + return false; + } + + return Date.now() - this.initSentAt >= this.getInitRetryDelay(); + } + + private getInitRetryDelay() { + if (!this.initRetryCount) { + return SCTP_INIT_RETRY_DELAY; + } + + return Math.min(SCTP_INIT_RETRY_DELAY * 2 ** (this.initRetryCount - 1), SCTP_MAX_INIT_RETRY_DELAY); + } + + private createInitRetryPacket() { + this.initSent = true; + this.initSentAt = Date.now(); + this.initRetryCount++; + return Array.from(this.createInitPacket()); + } + + drainPackets() { + const result = this.pendingPackets; + this.pendingPackets = []; + return result; + } + + receive(packet: Buffer) { + const payloads: Buffer[] = []; + if (packet.length < 12) { + logSctp('packet dropped: too short', { + length: packet.length, + }); + return payloads; + } + + if (!hasValidSctpChecksum(packet)) { + logSctp('packet dropped: invalid CRC32C', { + length: packet.length, + sourcePort: packet.readUInt16BE(0), + destinationPort: packet.readUInt16BE(2), + verificationTag: packet.readUInt32BE(4), + }); + return payloads; + } + + const chunks = parseSctpChunks(packet); + chunks.forEach((chunk) => { + if (!this.validateVerificationTag(packet, chunk.type)) { + logSctp('chunk dropped: invalid verification tag', { + chunkType: chunk.type, + verificationTag: packet.readUInt32BE(4), + expectedVerificationTag: chunk.type === SCTP_INIT ? 0 : this.localTag, + }); + return; + } + + if (chunk.type === SCTP_INIT) { + this.handleInit(chunk.body); + } else if (chunk.type === SCTP_INIT_ACK) { + this.handleInitAck(chunk.body); + } else if (chunk.type === SCTP_COOKIE_ECHO) { + if (!this.validateCookieEcho(chunk.body)) { + logSctp('COOKIE_ECHO ignored: invalid cookie', { + cookieLength: chunk.body.length, + expectedCookieLength: this.cookie.length, + }); + return; + } + this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ACK, 0, Buffer.alloc(0)))); + this.markEstablished(); + } else if (chunk.type === SCTP_COOKIE_ACK) { + this.markEstablished(); + } else if (chunk.type === SCTP_DATA) { + payloads.push(...this.handleData(chunk.flags, chunk.body)); + } else if (chunk.type === SCTP_SACK) { + this.handleSack(chunk.body); + } else if (chunk.type === SCTP_HEARTBEAT) { + this.pendingPackets.push(Array.from(this.createPacket(SCTP_HEARTBEAT_ACK, 0, chunk.body))); + } else if (chunk.type === SCTP_HEARTBEAT_ACK) { + // Nothing to do; accepting the chunk prevents Firefox/native peers from being logged as unsupported. + } else if (chunk.type === SCTP_ABORT) { + logSctp('ABORT received; resetting association', { + bodyLength: chunk.body.length, + }); + this.resetAssociation(); + } else { + logSctp('chunk ignored: unsupported type', { + chunkType: chunk.type, + flags: chunk.flags, + bodyLength: chunk.body.length, + }); + } + }); + + return payloads; + } + + private validateCookieEcho(cookie: Buffer) { + return Boolean(this.cookie.length && cookie.length === this.cookie.length && cookie.equals(this.cookie)); + } + + private validateVerificationTag(packet: Buffer, chunkType: number) { + const verificationTag = packet.readUInt32BE(4); + if (chunkType === SCTP_INIT) { + return verificationTag === 0; + } + + return verificationTag === this.localTag; + } + + private handleInit(body: Buffer) { + if (body.length < 16) { + logSctp('INIT ignored: body too short', { + bodyLength: body.length, + }); + return; + } + + this.peerTag = body.readUInt32BE(0); + this.peerInitialTsn = body.readUInt32BE(12); + this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0; + this.initSent = true; + this.cookie = this.createCookie(); + this.pendingPackets.push(Array.from(this.createInitAckPacket())); + } + + private handleInitAck(body: Buffer) { + if (body.length < 16) { + logSctp('INIT_ACK ignored: body too short', { + bodyLength: body.length, + }); + return; + } + + this.peerTag = body.readUInt32BE(0); + this.peerInitialTsn = body.readUInt32BE(12); + this.peerCumulativeTsn = (this.peerInitialTsn - 1) >>> 0; + + const cookie = findSctpParameter(body.slice(16), SCTP_STATE_COOKIE); + if (cookie) { + this.pendingPackets.push(Array.from(this.createPacket(SCTP_COOKIE_ECHO, 0, cookie))); + } else { + logSctp('INIT_ACK ignored: missing state cookie', { + bodyLength: body.length, + }); + } + } + + private handleData(flags: number, body: Buffer) { + if (body.length < 12) { + logSctp('DATA ignored: body too short', { + bodyLength: body.length, + }); + return []; + } + + const tsn = body.readUInt32BE(0); + const streamId = body.readUInt16BE(4); + const ppid = body.readUInt32BE(8); + if (streamId !== SCTP_STREAM_ID || ppid !== SCTP_BINARY_PPID) { + logSctp('DATA ignored: unsupported stream or PPID', { + streamId, + ppid, + expectedStreamId: SCTP_STREAM_ID, + expectedPpid: SCTP_BINARY_PPID, + }); + return []; + } + + const expectedTsn = this.getNextPeerTsn(); + if (expectedTsn !== undefined && tsn !== expectedTsn) { + if (isTsnAfter(tsn, expectedTsn)) { + this.bufferPendingPeerData(tsn, flags, body, expectedTsn); + } else { + logSctp('DATA ignored: duplicate TSN', { + tsn, + peerCumulativeTsn: this.peerCumulativeTsn, + expectedTsn, + }); + } + this.pendingPackets.push(Array.from(this.createSackPacket())); + return []; + } + + return this.acceptData(tsn, flags, body); + } + + private acceptData(tsn: number, flags: number, body: Buffer) { + const payloads: Buffer[] = []; + let currentTsn = tsn; + let currentFlags = flags; + let currentBody = body; + + while (true) { + const payload = this.readDataPayload(currentTsn, currentFlags, currentBody); + if (payload) { + payloads.push(payload); + } + + const nextTsn = this.getNextPeerTsn(); + const nextChunk = nextTsn === undefined ? undefined : this.pendingPeerData.get(nextTsn); + if (nextTsn === undefined || !nextChunk) { + break; + } + + this.deletePendingPeerData(nextTsn); + currentTsn = nextTsn; + currentFlags = nextChunk.flags; + currentBody = nextChunk.body; + } + + this.pendingPackets.push(Array.from(this.createSackPacket())); + return payloads; + } + + private readDataPayload(tsn: number, flags: number, body: Buffer) { + this.markEstablished(); + this.peerCumulativeTsn = tsn; + + const userData = body.slice(12); + const isBegin = Boolean(flags & 0x02); + const isEnd = Boolean(flags & 0x01); + if (isBegin && isEnd) { + return userData; + } + + if (isBegin) { + this.reassembly = [userData]; + return undefined; + } + + if (!this.reassembly) { + logSctp('DATA ignored: missing reassembly start', { + flags, + tsn, + }); + return undefined; + } + + this.reassembly.push(userData); + if (!isEnd) { + return undefined; + } + + const result = Buffer.concat(this.reassembly); + this.reassembly = undefined; + return result; + } + + private handleSack(body: Buffer) { + if (body.length < 12) { + logSctp('SACK ignored: body too short', { + bodyLength: body.length, + }); + return; + } + + this.markEstablished(); + } + + private getNextPeerTsn() { + if (this.peerCumulativeTsn === undefined) { + return undefined; + } + + return (this.peerCumulativeTsn + 1) >>> 0; + } + + private bufferPendingPeerData(tsn: number, flags: number, body: Buffer, expectedTsn: number) { + if (this.pendingPeerData.has(tsn)) { + logSctp('DATA ignored: already buffered TSN', { + tsn, + expectedTsn, + pendingCount: this.pendingPeerData.size, + pendingBytes: this.pendingPeerDataSize, + }); + return; + } + + const tsnGap = getForwardTsnGap(tsn, expectedTsn); + if ( + tsnGap > SCTP_MAX_PENDING_PEER_TSN_GAP + || this.pendingPeerData.size >= SCTP_MAX_PENDING_PEER_DATA_CHUNKS + || this.pendingPeerDataSize + body.length > SCTP_MAX_PENDING_PEER_DATA_BYTES + ) { + logSctp('DATA ignored: TSN gap buffer full', { + tsn, + peerCumulativeTsn: this.peerCumulativeTsn, + expectedTsn, + tsnGap, + pendingCount: this.pendingPeerData.size, + pendingBytes: this.pendingPeerDataSize, + }); + return; + } + + this.pendingPeerData.set(tsn, { flags, body }); + this.pendingPeerDataSize += body.length; + logSctp('DATA buffered: TSN gap', { + tsn, + peerCumulativeTsn: this.peerCumulativeTsn, + expectedTsn, + tsnGap, + pendingCount: this.pendingPeerData.size, + pendingBytes: this.pendingPeerDataSize, + }); + } + + private deletePendingPeerData(tsn: number) { + const chunk = this.pendingPeerData.get(tsn); + if (!chunk) { + return; + } + + this.pendingPeerDataSize -= chunk.body.length; + this.pendingPeerData.delete(tsn); + } + + private clearPendingPeerData() { + this.pendingPeerData.clear(); + this.pendingPeerDataSize = 0; + } + + private flushPendingPayloads() { + if (this.peerTag === undefined) { + return; + } + + const payloads = this.pendingPayloads; + this.pendingPayloads = []; + payloads.forEach((payload) => { + this.pendingPackets.push(Array.from(this.createDataPacket(payload))); + }); + } + + private markEstablished() { + if (this.isEstablished) { + return; + } + + this.isEstablished = true; + this.flushPendingPayloads(); + } + + private resetAssociation() { + this.localTag = generateUint32(); + this.localTsn = generateUint32(); + this.localSsn = 0; + this.peerTag = undefined; + this.peerInitialTsn = undefined; + this.peerCumulativeTsn = undefined; + this.initSent = false; + this.initSentAt = 0; + this.initRetryCount = 0; + this.isEstablished = false; + this.cookie = Buffer.alloc(0); + this.pendingPayloads = []; + this.pendingPackets = []; + this.clearPendingPeerData(); + this.reassembly = undefined; + } + + private createInitPacket() { + const body = Buffer.alloc(16); + body.writeUInt32BE(this.localTag, 0); + body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4); + body.writeUInt16BE(0xFFFF, 8); + body.writeUInt16BE(0xFFFF, 10); + body.writeUInt32BE(this.localTsn, 12); + return this.createPacket(SCTP_INIT, 0, body, 0); + } + + private createInitAckPacket() { + const body = Buffer.alloc(16); + body.writeUInt32BE(this.localTag, 0); + body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4); + body.writeUInt16BE(0xFFFF, 8); + body.writeUInt16BE(0xFFFF, 10); + body.writeUInt32BE(this.localTsn, 12); + + return this.createPacket(SCTP_INIT_ACK, 0, Buffer.concat([ + body, + createSctpParameter(SCTP_STATE_COOKIE, this.cookie), + ])); + } + + private createDataPacket(payload: Buffer) { + const body = Buffer.alloc(12 + payload.length); + body.writeUInt32BE(this.localTsn, 0); + this.localTsn = (this.localTsn + 1) >>> 0; + body.writeUInt16BE(SCTP_STREAM_ID, 4); + body.writeUInt16BE(this.localSsn, 6); + this.localSsn = (this.localSsn + 1) & 0xFFFF; + body.writeUInt32BE(SCTP_BINARY_PPID, 8); + payload.copy(body, 12); + return this.createPacket(SCTP_DATA, 0x03, body); + } + + private createSackPacket() { + const body = Buffer.alloc(12); + body.writeUInt32BE(this.peerCumulativeTsn || 0, 0); + body.writeUInt32BE(SCTP_RECEIVE_WINDOW, 4); + body.writeUInt16BE(0, 8); + body.writeUInt16BE(0, 10); + return this.createPacket(SCTP_SACK, 0, body); + } + + private createPacket(type: number, flags: number, body: Buffer, verificationTag = this.peerTag || 0) { + const chunk = createSctpChunk(type, flags, body); + const packet = Buffer.alloc(12 + chunk.length); + packet.writeUInt16BE(SCTP_PORT, 0); + packet.writeUInt16BE(SCTP_PORT, 2); + packet.writeUInt32BE(verificationTag, 4); + chunk.copy(packet, 12); + packet.writeUInt32LE(crc32c(packet), 8); + return packet; + } + + private createCookie() { + const cookie = Buffer.alloc(16); + cookie.writeUInt32BE(this.localTag, 0); + cookie.writeUInt32BE(this.peerTag || 0, 4); + cookie.writeUInt32BE(this.localTsn, 8); + cookie.writeUInt32BE(this.peerInitialTsn || 0, 12); + return cookie; + } +} + +function createSctpChunk(type: number, flags: number, body: Buffer) { + const length = 4 + body.length; + const paddedLength = align4(length); + const chunk = Buffer.alloc(paddedLength); + chunk[0] = type; + chunk[1] = flags; + chunk.writeUInt16BE(length, 2); + body.copy(chunk, 4); + return chunk; +} + +function createSctpParameter(type: number, value: Buffer) { + const length = 4 + value.length; + const parameter = Buffer.alloc(align4(length)); + parameter.writeUInt16BE(type, 0); + parameter.writeUInt16BE(length, 2); + value.copy(parameter, 4); + return parameter; +} + +function findSctpParameter(parameters: Buffer, type: number) { + let offset = 0; + while (offset + 4 <= parameters.length) { + const parameterType = parameters.readUInt16BE(offset); + const length = parameters.readUInt16BE(offset + 2); + if (length < 4 || offset + length > parameters.length) { + return undefined; + } + + if (parameterType === type) { + return parameters.slice(offset + 4, offset + length); + } + + offset += align4(length); + } + + return undefined; +} + +function parseSctpChunks(packet: Buffer) { + const chunks: SctpChunk[] = []; + let offset = 12; + while (offset + 4 <= packet.length) { + const type = packet[offset]; + const flags = packet[offset + 1]; + const length = packet.readUInt16BE(offset + 2); + if (length < 4 || offset + length > packet.length) { + break; + } + + chunks.push({ + type, + flags, + body: packet.slice(offset + 4, offset + length), + }); + offset += align4(length); + } + + return chunks; +} + +export function isSctpPacket(packet: Buffer) { + return packet.length >= 12 + && packet.readUInt16BE(0) === SCTP_PORT + && packet.readUInt16BE(2) === SCTP_PORT; +} + +function hasValidSctpChecksum(packet: Buffer) { + return isSctpPacket(packet) && packet.readUInt32LE(8) === crc32c(packet); +} + +function align4(value: number) { + return (value + 3) & ~3; +} + +function generateUint32() { + const values = new Uint32Array(1); + crypto.getRandomValues(values); + + return values[0] >>> 0; +} + +function isTsnAfter(tsn: number, expectedTsn: number) { + return ((tsn - expectedTsn) >>> 0) < 0x80000000; +} + +function getForwardTsnGap(tsn: number, expectedTsn: number) { + return (tsn - expectedTsn) >>> 0; +} + +function createCrc32cTable() { + const table: number[] = []; + for (let i = 0; i < 256; i++) { + let value = i; + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? (0x82F63B78 ^ (value >>> 1)) : value >>> 1; + } + table[i] = value >>> 0; + } + return table; +} + +function crc32c(data: Buffer) { + const buffer = Buffer.from(data); + buffer.writeUInt32LE(0, 8); + let crc = 0xFFFFFFFF; + for (let i = 0; i < buffer.length; i++) { + crc = CRC32C_TABLE[(crc ^ buffer[i]) & 0xFF] ^ (crc >>> 8); + } + return (~crc) >>> 0; +} + +function logSctp(message: string, data: Record = {}) { + if (!DEBUG_CALLS) { + return; + } + + // eslint-disable-next-line no-console + console.debug(`[PhoneCall][SCTP] ${message}`, data); +} diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 937c6ea20..1198f83bf 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -1,7 +1,7 @@ import { Api as GramJs, type Update } from '../../../lib/gramjs'; import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gramjs/network'; -import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; +import type { GroupCallConnectionData } from '../../../lib/vibecalls'; import { type ApiMessage, type ApiMessagePoll, diff --git a/src/api/types/calls.ts b/src/api/types/calls.ts index 85af8a151..fc1395fd9 100644 --- a/src/api/types/calls.ts +++ b/src/api/types/calls.ts @@ -4,7 +4,10 @@ import type { GroupCallParticipant, VideoRotation, VideoState, -} from '../../lib/secret-sauce'; +} from '../../lib/vibecalls'; +import type { PrimitiveRecord } from '../../util/primitives/primitiveRecord'; + +export type ApiPhoneCallCustomParameters = PrimitiveRecord; export interface ApiGroupCall { chatId?: string; @@ -26,6 +29,8 @@ export interface ApiGroupCall { inviteHash?: string; nextOffset?: string; + localSource?: number; + localJoinAsId?: string; participants: Record; connectionState: GroupCallConnectionState; isSpeakerDisabled?: boolean; @@ -50,6 +55,7 @@ export interface ApiPhoneCall { needDebug?: boolean; reason?: 'missed' | 'disconnect' | 'hangup' | 'busy'; duration?: number; + customParameters?: ApiPhoneCallCustomParameters; emojis?: string; gA?: number[]; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index f5703ad90..87e576520 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -5,7 +5,7 @@ import type { GroupCallParticipant, VideoRotation, VideoState, -} from '../../lib/secret-sauce'; +} from '../../lib/vibecalls'; import type { ThreadId, ThreadReadState, TranslationTone } from '../../types'; import type { RegularLangFnParameters } from '../../util/localization'; import type { ApiBotCommand, ApiBotMenuButton } from './bots'; diff --git a/src/components/calls/group/GroupCall.tsx b/src/components/calls/group/GroupCall.tsx index 8e9f3f031..d48925e3f 100644 --- a/src/components/calls/group/GroupCall.tsx +++ b/src/components/calls/group/GroupCall.tsx @@ -8,10 +8,10 @@ import { getActions, withGlobal } from '../../../global'; import type { GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant, -} from '../../../lib/secret-sauce'; +} from '../../../lib/vibecalls'; import type { VideoParticipant } from './hooks/useGroupCallVideoLayout'; -import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce'; +import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/vibecalls'; import { selectChat, selectTabState } from '../../../global/selectors'; import { selectCanInviteToActiveGroupCall, diff --git a/src/components/calls/group/GroupCallParticipant.tsx b/src/components/calls/group/GroupCallParticipant.tsx index bac22298e..e56188ee2 100644 --- a/src/components/calls/group/GroupCallParticipant.tsx +++ b/src/components/calls/group/GroupCallParticipant.tsx @@ -5,10 +5,10 @@ import { import { withGlobal } from '../../../global'; import type { ApiPeer } from '../../../api/types'; -import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; +import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls'; import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config'; -import { THRESHOLD } from '../../../lib/secret-sauce'; +import { THRESHOLD } from '../../../lib/vibecalls'; import { selectChat, selectUser } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import renderText from '../../common/helpers/renderText'; diff --git a/src/components/calls/group/GroupCallParticipantList.tsx b/src/components/calls/group/GroupCallParticipantList.tsx index ddbb95607..c722b0393 100644 --- a/src/components/calls/group/GroupCallParticipantList.tsx +++ b/src/components/calls/group/GroupCallParticipantList.tsx @@ -2,7 +2,7 @@ import type { FC } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; +import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls'; import { selectActiveGroupCall } from '../../../global/selectors/calls'; import buildClassName from '../../../util/buildClassName'; diff --git a/src/components/calls/group/GroupCallParticipantMenu.tsx b/src/components/calls/group/GroupCallParticipantMenu.tsx index d14e77d81..1830c3024 100644 --- a/src/components/calls/group/GroupCallParticipantMenu.tsx +++ b/src/components/calls/group/GroupCallParticipantMenu.tsx @@ -3,7 +3,7 @@ import type React from '../../../lib/teact/teact'; import { memo, useEffect, useState } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { GroupCallParticipant } from '../../../lib/secret-sauce'; +import type { GroupCallParticipant } from '../../../lib/vibecalls'; import type { MenuPositionOptions } from '../../ui/Menu'; import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; diff --git a/src/components/calls/group/GroupCallParticipantVideo.tsx b/src/components/calls/group/GroupCallParticipantVideo.tsx index 704c772b1..376b35bf8 100644 --- a/src/components/calls/group/GroupCallParticipantVideo.tsx +++ b/src/components/calls/group/GroupCallParticipantVideo.tsx @@ -5,13 +5,13 @@ import { import { withGlobal } from '../../../global'; import type { ApiChat, ApiUser } from '../../../api/types'; -import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; +import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/vibecalls'; import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout'; import { GROUP_CALL_DEFAULT_VOLUME } from '../../../config'; import fastBlur from '../../../lib/fastBlur'; import { requestMutation } from '../../../lib/fasterdom/fasterdom'; -import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce'; +import { getUserStreams, THRESHOLD } from '../../../lib/vibecalls'; import { selectChat, selectUser } from '../../../global/selectors'; import { animate } from '../../../util/animation'; import { IS_CANVAS_FILTER_SUPPORTED } from '../../../util/browser/windowEnvironment'; diff --git a/src/components/calls/group/MicrophoneButton.tsx b/src/components/calls/group/MicrophoneButton.tsx index eb6a2bc01..90c90fafe 100644 --- a/src/components/calls/group/MicrophoneButton.tsx +++ b/src/components/calls/group/MicrophoneButton.tsx @@ -4,7 +4,7 @@ import { } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { GroupCallConnectionState } from '../../../lib/secret-sauce'; +import type { GroupCallConnectionState } from '../../../lib/vibecalls'; import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../global/selectors/calls'; import buildClassName from '../../../util/buildClassName'; diff --git a/src/components/calls/group/OutlinedMicrophoneIcon.tsx b/src/components/calls/group/OutlinedMicrophoneIcon.tsx index bd33691bc..b4d8ccf60 100644 --- a/src/components/calls/group/OutlinedMicrophoneIcon.tsx +++ b/src/components/calls/group/OutlinedMicrophoneIcon.tsx @@ -1,9 +1,9 @@ import type { FC } from '../../../lib/teact/teact'; import { memo, useMemo } from '../../../lib/teact/teact'; -import type { GroupCallParticipant } from '../../../lib/secret-sauce'; +import type { GroupCallParticipant } from '../../../lib/vibecalls'; -import { THRESHOLD } from '../../../lib/secret-sauce'; +import { THRESHOLD } from '../../../lib/vibecalls'; import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; diff --git a/src/components/calls/group/helpers/formatGroupCallVolume.ts b/src/components/calls/group/helpers/formatGroupCallVolume.ts index a416035aa..fe695427a 100644 --- a/src/components/calls/group/helpers/formatGroupCallVolume.ts +++ b/src/components/calls/group/helpers/formatGroupCallVolume.ts @@ -1,4 +1,4 @@ -import type { GroupCallParticipant } from '../../../../lib/secret-sauce'; +import type { GroupCallParticipant } from '../../../../lib/vibecalls'; import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../../config'; diff --git a/src/components/calls/phone/PhoneCall.module.scss b/src/components/calls/phone/PhoneCall.module.scss index 6bd8e5c98..1c3442b96 100644 --- a/src/components/calls/phone/PhoneCall.module.scss +++ b/src/components/calls/phone/PhoneCall.module.scss @@ -48,6 +48,10 @@ } } +.fullscreenDialog { + border-radius: 0; +} + .header { position: absolute; diff --git a/src/components/calls/phone/PhoneCall.tsx b/src/components/calls/phone/PhoneCall.tsx index a24f4b8c5..e724f656c 100644 --- a/src/components/calls/phone/PhoneCall.tsx +++ b/src/components/calls/phone/PhoneCall.tsx @@ -9,7 +9,7 @@ import type { ApiPhoneCall, ApiUser } from '../../../api/types'; import { getStreams, IS_SCREENSHARE_SUPPORTED, switchCameraInputP2p, toggleStreamP2p, -} from '../../../lib/secret-sauce'; +} from '../../../lib/vibecalls'; import { selectTabState } from '../../../global/selectors'; import { selectPhoneCallUser } from '../../../global/selectors/calls'; import { @@ -59,42 +59,56 @@ const PhoneCall = ({ const [isFullscreen, openFullscreen, closeFullscreen] = useFlag(); const { isMobile } = useAppLayout(); - const toggleFullscreen = useCallback(() => { - if (isFullscreen) { - closeFullscreen(); + const isOpen = Boolean(phoneCall && phoneCall.state !== 'discarded' && !isCallPanelVisible); + + const exitFullscreenIfNeeded = useCallback(() => { + if (document.fullscreenElement === containerRef.current) { + void document.exitFullscreen().catch(() => undefined).then(closeFullscreen); } else { - openFullscreen(); + closeFullscreen(); } - }, [closeFullscreen, isFullscreen, openFullscreen]); + }, [closeFullscreen]); const handleToggleFullscreen = useCallback(() => { if (!containerRef.current) return; if (isFullscreen) { - document.exitFullscreen().then(closeFullscreen); + exitFullscreenIfNeeded(); } else { - containerRef.current.requestFullscreen().then(openFullscreen); + void containerRef.current.requestFullscreen().then(openFullscreen).catch(() => undefined); } - }, [closeFullscreen, isFullscreen, openFullscreen]); + }, [exitFullscreenIfNeeded, isFullscreen, openFullscreen]); useEffect(() => { if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined; - const container = containerRef.current; - if (!container) return undefined; - container.addEventListener('fullscreenchange', toggleFullscreen); + const handleFullscreenChange = () => { + if (document.fullscreenElement === containerRef.current) { + openFullscreen(); + } else { + closeFullscreen(); + } + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); return () => { - container.removeEventListener('fullscreenchange', toggleFullscreen); + document.removeEventListener('fullscreenchange', handleFullscreenChange); }; - }, [toggleFullscreen]); + }, [closeFullscreen, openFullscreen]); + + useEffect(() => { + if (isOpen || !isFullscreen) return; + + exitFullscreenIfNeeded(); + }, [exitFullscreenIfNeeded, isFullscreen, isOpen]); const handleClose = useCallback(() => { toggleGroupCallPanel(); if (isFullscreen) { - closeFullscreen(); + exitFullscreenIfNeeded(); } - }, [closeFullscreen, isFullscreen, toggleGroupCallPanel]); + }, [exitFullscreenIfNeeded, isFullscreen, toggleGroupCallPanel]); const isDiscarded = phoneCall?.state === 'discarded'; const isBusy = phoneCall?.reason === 'busy'; @@ -161,9 +175,9 @@ const PhoneCall = ({ const hasPresentation = phoneCall?.screencastState === 'active'; const streams = getStreams(); - const hasOwnAudio = streams?.ownAudio?.getTracks()[0].enabled; - const hasOwnPresentation = streams?.ownPresentation?.getTracks()[0].enabled; - const hasOwnVideo = streams?.ownVideo?.getTracks()[0].enabled; + const hasOwnAudio = streams?.ownAudio?.getTracks()?.[0]?.enabled ?? false; + const hasOwnPresentation = streams?.ownPresentation?.getTracks()?.[0]?.enabled ?? false; + const hasOwnVideo = streams?.ownVideo?.getTracks()?.[0]?.enabled ?? false; const [isHidingPresentation, startHidingPresentation, stopHidingPresentation] = useFlag(); const [isHidingVideo, startHidingVideo, stopHidingVideo] = useFlag(); @@ -228,12 +242,13 @@ const PhoneCall = ({ return (