diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index 06884c7b4..06da02502 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -142,7 +142,7 @@ describe('Call', function() { await call.onAnswerReceived({ getContent: () => { return { - version: 0, + version: 1, call_id: call.callId, party_id: 'the_correct_party_id', answer: { @@ -156,7 +156,7 @@ describe('Call', function() { call.onRemoteIceCandidatesReceived({ getContent: () => { return { - version: 0, + version: 1, call_id: call.callId, party_id: 'the_correct_party_id', candidates: [ @@ -173,7 +173,7 @@ describe('Call', function() { call.onRemoteIceCandidatesReceived({ getContent: () => { return { - version: 0, + version: 1, call_id: call.callId, party_id: 'some_other_party_id', candidates: [ diff --git a/src/@types/event.ts b/src/@types/event.ts index 7ebc24342..d54920a62 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -48,6 +48,7 @@ export enum EventType { CallReject = "m.call.reject", CallSelectAnswer = "m.call.select_answer", CallNegotiate = "m.call.negotiate", + CallReplaces = "m.call.replaces", KeyVerificationRequest = "m.key.verification.request", KeyVerificationStart = "m.key.verification.start", KeyVerificationCancel = "m.key.verification.cancel", diff --git a/src/client.js b/src/client.js index 0f860c44f..338afac17 100644 --- a/src/client.js +++ b/src/client.js @@ -180,6 +180,9 @@ function keyFromRecoverySession(session, decryptionKey) { * @param {boolean} [opts.forceTURN] * Optional. Whether relaying calls through a TURN server should be forced. * + * @param {boolean} [opts.supportsTransfer] + * Optional. True to advertise support for call transfers to other parties on Matrix calls. + * * @param {boolean} [opts.fallbackICEServerAllowed] * Optional. Whether to allow a fallback ICE server should be used for negotiating a * WebRTC connection if the homeserver doesn't provide any servers. Defaults to false. @@ -364,6 +367,7 @@ export function MatrixClient(opts) { this._cryptoCallbacks = opts.cryptoCallbacks || {}; this._forceTURN = opts.forceTURN || false; + this._supportsTransfer = opts.supportsTransfer || false; this._fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false; // List of which rooms have encryption enabled: separate from crypto because @@ -696,6 +700,14 @@ MatrixClient.prototype.setForceTURN = function(forceTURN) { this._forceTURN = forceTURN; }; +/** + * Set whether to advertise trabsfer support to other parties on Matrix calls. + * @param {bool} supportsTransfer True to advertise the 'm.call.transeree' capability + */ +MatrixClient.prototype.setSupportsTransfer = function(supportsTransfer) { + this._supportsTransfer = supportsTransfer; +}; + /** * Get the current sync state. * @return {?string} the sync state, which may be null. diff --git a/src/matrix.ts b/src/matrix.ts index 0f4dcce6b..d2e2ea464 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -142,6 +142,7 @@ export interface ICreateClientOpts { unstableClientRelationAggregation?: boolean; verificationMethods?: Array; forceTURN?: boolean; + supportsTransfer?: boolean, fallbackICEServerAllowed?: boolean; cryptoCallbacks?: ICryptoCallbacks; } diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index fea0db149..1f414adc1 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -27,6 +27,7 @@ import * as utils from '../utils'; import MatrixEvent from '../models/event'; import {EventType} from '../@types/event'; import { RoomMember } from '../models/room-member'; +import { randomString } from '../randomstring'; // events: hangup, error(err), replaced(call), state(state, oldState) @@ -60,6 +61,10 @@ interface TurnServer { ttl?: number, } +interface CallCapabilities { + 'm.call.transferee': boolean, +} + export enum CallState { Fledgling = 'fledgling', InviteSent = 'invite_sent', @@ -194,6 +199,10 @@ export class CallError extends Error { } } +function genCallID() { + return Date.now() + randomString(16); +} + /** * Construct a new Matrix Call. * @constructor @@ -240,6 +249,7 @@ export class MatrixCall extends EventEmitter { // The party ID of the other side: undefined if we haven't chosen a partner // yet, null if we have but they didn't send a party ID. private opponentPartyId: string; + private opponentCaps: CallCapabilities; private inviteTimeout: NodeJS.Timeout; // in the browser it's 'number' // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold @@ -275,7 +285,7 @@ export class MatrixCall extends EventEmitter { utils.checkObjectHasKeys(server, ["urls"]); } - this.callId = "c" + new Date().getTime() + Math.random(); + this.callId = genCallID(); this.state = CallState.Fledgling; // A queue for candidates waiting to go out. @@ -358,6 +368,10 @@ export class MatrixCall extends EventEmitter { return this.opponentMember; } + opponentCanBeTransferred() { + return Boolean(this.opponentCaps && this.opponentCaps["m.call.transferee"]); + } + /** * Retrieve the local <video> DOM element. * @return {Element} The dom element @@ -469,7 +483,11 @@ export class MatrixCall extends EventEmitter { this.setState(CallState.Ringing); this.opponentVersion = this.msg.version; - this.opponentPartyId = this.msg.party_id || null; + if (this.opponentVersion !== 0) { + // ignore party ID in v0 calls: party ID isn't a thing until v1 + this.opponentPartyId = this.msg.party_id || null; + } + this.opponentCaps = this.msg.capabilities || {}; this.opponentMember = event.sender; if (event.getLocalAge()) { @@ -779,7 +797,14 @@ export class MatrixCall extends EventEmitter { // required to still be sent for backwards compat type: this.peerConn.localDescription.type, }, - }; + } as any; + + if (this.client._supportsTransfer) { + answerContent.capabilities = { + 'm.call.transferee': true, + } + } + // We have just taken the local description from the peerconnection which will // contain all the local candidates added so far, so we can discard any candidates // we had queued up because they'll be in the answer. @@ -959,7 +984,10 @@ export class MatrixCall extends EventEmitter { } this.opponentVersion = event.getContent().version; - this.opponentPartyId = event.getContent().party_id || null; + if (this.opponentVersion !== 0) { + this.opponentPartyId = event.getContent().party_id || null; + } + this.opponentCaps = event.getContent().capabilities || {}; this.opponentMember = event.sender; this.setState(CallState.Connecting); @@ -1102,7 +1130,13 @@ export class MatrixCall extends EventEmitter { const content = { [keyName]: this.peerConn.localDescription, lifetime: CALL_TIMEOUT_MS, - }; + } as any; + + if (this.client._supportsTransfer) { + content.capabilities = { + 'm.call.transferee': true, + } + } // Get rid of any candidates waiting to be sent: they'll be included in the local // description we just got and will send in the offer. @@ -1379,6 +1413,28 @@ export class MatrixCall extends EventEmitter { } } + async transfer(targetUserId: string, targetRoomId?: string) { + // Fetch the target user's global profile info: their room avatar / displayname + // could be different in whatever room we shae with them. + const profileInfo = await this.client.getProfileInfo(targetUserId); + + const replacementId = genCallID(); + + const body = { + replacement_id: genCallID(), + target_user: { + id: targetUserId, + display_name: profileInfo.display_name, + avatar_url: profileInfo.avatar_url, + }, + create_call: replacementId, + } as any; + + if (targetRoomId) body.target_room = targetRoomId; + + return this.sendVoipEvent(EventType.CallReplaces, body); + } + private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean) { if (this.callHasEnded()) return;