src/calls/Call.js
import {Set} from 'immutable';
import {cloneDeep, pick} from 'lodash-es';
import bowser from 'bowser';
import {EventEmitter} from '../eventemitter';
import {bindMethods} from '../utils';
import Logs from '../Logs';
import {ManagedStream} from '../media/ManagedStream';
import {LocalMediaHandler} from '../media/LocalMediaHandler';
import {CallDirection} from './CallDirection';
import {CallEndReason, getEndReasonFromWacValue} from './CallEndReason';
import {CallStatus} from './CallStatus';
import {CallRemoteRecorder} from './CallRemoteRecorder';
const log = Logs.instance.getLogger('SippoJS/Call');
/**
* @typedef {Object} MediaTypes
* @property {boolean} audio
* @property {boolean} video
* @property {boolean} screen
*/
/**
* Provides access to methods for managing outgoing or incoming calls.
* {@link Call} objects are obtained calling {@link CallManager#createCall} method or handling
* the `onIncoming` event of a connected {@link CallManager} instance.
*
* ## Events
*
* - `localOnlyStreams` ({@link ImmutableSet}<{@link ManagedStream}> currentValue, {@link ImmutableSet}<{@link ManagedStream}> oldValue)
* - Emitted every time localOnlyStreams changes.
* - `remoteStreams` ({@link ImmutableSet}<{@link ManagedStream}> currentValue, {@link ImmutableSet}<{@link ManagedStream}> oldValue)
* - Emitted every time remoteStreams changes.
* - `transferred`
* - Emitted every time remote end of the call changes.
* - `context` ({@link Object})
* - Emitted every time context changes.
* - `status` ({@link CallStatus})
* - Emitted every time status changes.
*/
export class Call {
/** @protected */
static newInstance(wacProxy, userManager, {
gatewayCaller,
gatewayCallee,
remoteUser,
id = null,
direction = CallDirection.INCOMING,
mediaConstraints = {audio: true, video: true},
pollingCall = false,
pullFrom = null,
} = {}) {
const call = new Call(
wacProxy, userManager, gatewayCaller, gatewayCallee, remoteUser, id,
direction, mediaConstraints, pollingCall, pullFrom,
);
return call.initialize().then(() => call);
}
/** @private */
constructor(
wacProxy, userManager, gatewayCaller, gatewayCallee, remoteUser, id,
direction, mediaConstraints, pollingCall, pullFrom,
) {
/** @private */
this.wacProxy = wacProxy;
/** @private */
this.userManager = userManager;
/** @private */
this.callService = wacProxy.getCallService();
/** @private */
this.callAttemptService = wacProxy.getCallAttemptService();
/** @private */
this.id = id;
/** @private */
this.wacId = '.invalid';
/** @private */
this.direction = direction;
/** @private */
this.gatewayCaller = gatewayCaller;
/** @private */
this.gatewayCallee = gatewayCallee;
/** @private */
this.originalGatewayCallee = gatewayCallee;
/** @private */
this.remoteUser = remoteUser;
/** @private */
this.pollingCall = pollingCall;
/** @private */
this.pullFrom = pullFrom;
/** @private */
this.beforeShareScreenVideo = true;
/** @private */
this.status = direction === CallDirection.INCOMING ? CallStatus.TRYING : CallStatus.UNCONNECTED;
/** @private */
this.endReason = CallEndReason.UNSPECIFIED;
/** @private */
this.localOnlyStreams = new Set();
/** @private */
this.localMediaHandler = new LocalMediaHandler(mediaConstraints, this.updateMedia.bind(this));
/** @private */
this.remoteStreams = new Set();
/** @private */
this.context = null;
/** @type {EventEmitter} */
this.emitter = new EventEmitter();
bindMethods(this, [
'onRinging',
'onEstablished',
'onLocalStream',
'onRemoteStream',
'onEndCall',
'onHoldReceived',
'onResumeReceived',
'onTransferred',
'onCallAttemptRejected',
]);
}
/** @private */
initialize() {
if (this.direction === CallDirection.INCOMING) {
this.bindEventListeners();
}
return this.localMediaHandler.init();
}
/** @private */
bindEventListeners() {
this.wacProxy.on('qs-ringing', this.onRinging);
this.wacProxy.on('qs-established', this.onEstablished);
this.wacProxy.on('qs-localstream', this.onLocalStream);
this.wacProxy.on('qs-remotestream', this.onRemoteStream);
this.wacProxy.on('qs-end-call qs-lost-call', this.onEndCall);
this.wacProxy.on('qs-onhold-received', this.onHoldReceived);
this.wacProxy.on('qs-resume-received', this.onResumeReceived);
this.wacProxy.on('qs-transferred', this.onTransferred);
this.callAttemptService.emitter.on('rejected', this.onCallAttemptRejected);
}
/** @private */
unbindEventListeners() {
this.wacProxy.off('qs-ringing', this.onRinging);
this.wacProxy.off('qs-established', this.onEstablished);
this.wacProxy.off('qs-localstream', this.onLocalStream);
this.wacProxy.off('qs-remotestream', this.onRemoteStream);
this.wacProxy.off('qs-end-call qs-lost-call', this.onEndCall);
this.wacProxy.off('qs-onhold-received', this.onHoldReceived);
this.wacProxy.off('qs-resume-received', this.onResumeReceived);
this.wacProxy.off('qs-transferred', this.onTransferred);
this.callAttemptService.emitter.off('rejected', this.onCallAttemptRejected);
}
/** @private */
onRinging(event) {
if (event.userid !== this.gatewayCaller || this.isPolling()) {
return;
}
if (this.wacProxy.hasCapability('call-rehydration')) {
this.reconnect(event.callid);
}
}
/** @private */
onEstablished(event) {
if (event.callid !== this.id) {
return;
}
this.updateCallControl('active').then(() => {
this.setStatus(CallStatus.CONNECTED);
});
}
/** @private */
onLocalStream(event) {
if (event.callid !== this.id) {
return;
}
this.clearLocalOnlyStreams();
this.localMediaHandler.setVideoStream(event.stream);
}
/** @private */
onRemoteStream(event) {
if (event.callid !== this.id) {
return;
}
if (event.stream) {
// Ignore stream updates of an already known stream
if (this.remoteStreams.find(mS => mS.mediaStream === event.stream)) {
return;
}
const managedStream = new ManagedStream(event.stream);
const old = this.remoteStreams;
this.remoteStreams = this.remoteStreams.add(managedStream);
this.emitter.emit('remoteStreams', this.remoteStreams, old);
} else {
this.clearRemoteStreams();
}
}
/** @private */
onEndCall(event) {
if (event.callid !== this.id) {
return;
}
const endReason = getEndReasonFromWacValue(event.endReason);
const hasRehydration = this.wacProxy.hasCapability('call-rehydration');
if (endReason === CallEndReason.NETWORK_ERROR && hasRehydration) {
return this.reconnect();
}
this.sendStats();
this.updateCallControl('finished', event.endReason).then(() => {
this.setStatus(CallStatus.DISCONNECTED, endReason);
});
}
/** @private */
onHoldReceived(event) {
if (event.callid !== this.id) {
return;
}
this.setStatus(CallStatus.REMOTE_HOLD);
}
/** @private */
onResumeReceived(event) {
if (event.callid !== this.id) {
return;
}
this.setStatus(CallStatus.CONNECTED);
}
/** @private */
onTransferred(event) {
if (event.callid !== this.id) {
return;
}
this.id = event.to;
if (this.direction === CallDirection.INCOMING) {
this.gatewayCaller = event.userid;
} else {
this.gatewayCallee = event.userid;
}
this.userManager.resolveUser(event.userid).then((user) => {
this.remoteUser = user;
this.setStatus(CallStatus.CONNECTED);
this.emitter.emit('transferred');
});
}
/** @private */
onCallAttemptRejected() {
if (event.callid !== this.id) {
return;
}
this.setStatus(CallStatus.DISCONNECTED, CallEndReason.USER_BUSY);
}
/** @private */
sendStats() {
const data = Object.assign({id: this.wacId}, this.stats);
this.wacProxy.sendStats('callcontrol', Date.now(), 'call-brief', data).then(() => {
log.debug('call brief sent');
}).catch(() => {
log.error('unable to send call brief');
});
}
/** @private */
createCallControl() {
if (!this.wacProxy.hasCapability('callcontrol')) {
return Promise.resolve();
}
const caller = this.wacProxy.getCurrentCredential().data.username;
return this.callService.create({
caller,
callee: this.gatewayCallee,
context: this.context,
anonymousSessionId: this.sessionId,
}).then((res) => {
this.wacId = res.id;
this.gatewayCallee = res.gatewayUsername;
this.context = res.context;
return res;
}).catch((error) => {
this.setStatus(CallStatus.DISCONNECTED, CallEndReason.SERVER_ERROR);
throw error;
});
}
/** @private */
updateCallControl(status, endReason = 'unknown') {
if (!this.wacProxy.hasCapability('callcontrol')) {
return Promise.resolve();
}
return this.callService.update(this.wacId, {
caller: this.gatewayCaller,
callee: this.gatewayCallee,
context: this.context,
endReason: endReason,
status: status,
}).then((res) => {
this.wacId = res.id;
this.context = res.context;
if (typeof this.context === 'object') {
this.emitter.emit('context', this.context);
}
return res;
});
}
/**
* @return {string}
*/
getId() {
return this.id;
}
/** @private */
updateMedia(audio, video, screen) {
if (!this.hasStatus(CallStatus.UNCONNECTED, CallStatus.TRYING, CallStatus.DISCONNECTED)) {
return this.wacProxy.updateCallMedia(this.id, audio, video, screen);
}
return Promise.resolve();
}
/**
* Allows access to local streams and actions related to changing which local media is shared
* @return {LocalMediaHandler}
*/
getLocalMediaHandler() {
return this.localMediaHandler;
}
/**
* Returns the direction of the call
* @return {CallDirection}
*/
getDirection() {
return this.direction;
}
/**
* Returns a user object representing the remote user if exists
* @return {User}
*/
getRemoteUser() {
return this.remoteUser;
}
/**
* Returns a the remote gateway user
* @return {string}
*/
getRemoteGatewayUser() {
return this.direction === CallDirection.INCOMING ? this.gatewayCaller : this.gatewayCallee;
}
/**
* Returns the current status of the call
* @return {CallStatus}
*/
getStatus() {
return this.status;
}
/**
* Returns true if Call is in any of the specified status
* @param {...CallStatus} args
* @return {boolean}
*/
hasStatus(...args) {
return args.includes(this.status);
}
/**
* Returns a promise that is resolved when the call changes to the requested status
* @param {...CallStatus} statusList
* @return {Promise<CallStatus>} A promise that is resolved with the received status
*/
waitForStatus(...statusList) {
return new Promise((resolve) => {
if (this.hasStatus(...statusList)) {
return resolve(this.status);
}
const onStatus = (newStatus) => {
if (!statusList.includes(newStatus)) {
return;
}
this.emitter.off('status', onStatus);
resolve(newStatus);
};
this.emitter.on('status', onStatus);
});
}
/**
* @private
*/
setStatus(status, endReason = CallEndReason.UNSPECIFIED) {
if (this.status === status) {
return;
}
this.status = status;
if (status === CallStatus.DISCONNECTED) {
this.unbindEventListeners();
this.clearLocalOnlyStreams();
this.localMediaHandler.setVideoStream();
this.clearRemoteStreams();
this.endReason = endReason;
}
this.emitter.emit('status', status);
log.log(this.direction, this.status, this.endReason);
}
/**
* Returns the end reason of the call. This value is only specified when
* the status of the call is DISCONNECTED
* @return {CallEndReason}
*/
getEndReason() {
return this.endReason;
}
/**
* Returns true if the Call is polling
* @return {boolean}
*/
isPolling() {
return this.pollingCall;
}
/**
* Returns a set containing the local only streams
* @return {ImmutableSet<ManagedStream>}
*/
getLocalOnlyStreams() {
return this.localOnlyStreams;
}
/**
* Returns a set containing the remote streams
* @return {ImmutableSet<ManagedStream>}
*/
getRemoteStreams() {
return this.remoteStreams;
}
/** @private */
clearLocalOnlyStreams() {
if (this.localOnlyStreams.isEmpty()) {
return;
}
this.localOnlyStreams.forEach(mS => mS.stop());
const oldStreams = this.localOnlyStreams;
this.localOnlyStreams = this.localOnlyStreams.clear();
this.emitter.emit('localOnlyStreams', this.localOnlyStreams, oldStreams);
}
/** @private */
clearRemoteStreams() {
if (this.remoteStreams.isEmpty()) {
return;
}
const old = this.remoteStreams;
this.remoteStreams = this.remoteStreams.clear();
this.emitter.emit('remoteStreams', this.remoteStreams, old);
}
/**
* Returns a boolean value indicating if the call has a local only video track
* @return {boolean}
*/
hasLocalOnlyVideo() {
return this.localOnlyStreams.some(stream => stream.hasVideoTracks());
}
/**
* Returns a boolean value indicating if the call has a remote audio track
* @return {boolean}
*/
hasRemoteAudio() {
return this.remoteStreams.some(stream => stream.hasAudioTracks());
}
/**
* Returns a boolean value indicating if the call has a remote video track
* @return {boolean}
*/
hasRemoteVideo() {
return this.remoteStreams.some(stream => stream.hasVideoTracks());
}
/**
* Returns actual call context
* @return {Object} call context
*/
getContext() {
return this.context;
}
/**
* Set some optional data that will be sent when creating the call.
* Note that this method must be used before the call is connected.
* @param {Object} context Any JSON serializable object that will be sent to
* the server when the call starts
* @return {Call}
*/
setContext(context) {
if (typeof context !== 'object') {
throw new TypeError(`${context} is not a JSON serializable object`);
}
this.context = cloneDeep(context);
return this;
}
/**
* Create an object to manage the remote recording
* @param {String} [type='remote'] Type of the recording
* @return {CallRemoteRecorder}
*/
createCallRecorder(type) {
if (type === 'remote' && this.wacProxy.hasCapability('recording')) {
return new CallRemoteRecorder(this.wacProxy, this.wacId);
}
}
/**
* Attempts to reach the call recipient and establish a connection.
* @return {Promise<Call, Error>}
*/
async connect() {
try {
if (this.getDirection() === CallDirection.OUTGOING) {
await this.connectOutgoing();
} else {
await this.connectIncoming();
}
return this;
} catch (error) {
log.error('connect failed with error', error);
if (!this.hasStatus(CallStatus.DISCONNECTED)) {
await this.updateCallControl('finished');
this.setStatus(CallStatus.DISCONNECTED);
}
throw error;
}
}
/** @private */
async connectOutgoing() {
if (this.getStatus === CallStatus.DISCONNECTED) {
this.setStatus(CallStatus.UNCONNECTED);
}
if (!this.hasStatus(CallStatus.UNCONNECTED)) {
throw new Error(`Invalid status. A call in ${CallStatus[this.status]} status can't be connected`);
}
this.setStatus(CallStatus.TRYING);
this.wacProxy.once('qs-calling', (event) => {
this.id = event.callid;
});
this.bindEventListeners();
await this.initializeLocalMedia();
const res = await this.createCallControl();
if (res.state === 'queued') {
await this.waitWhileQueued();
}
if (this.pullFrom) {
return this.wacProxy.pullCallFromSession(this.pullFrom.id, this.pullFrom.session.id);
}
const mediaConstraints = this.localMediaHandler.getCurrentMediaConstraints();
// TODO Return here a rejected promise if wacProxy.call fails
return this.wacProxy.call(this.gatewayCallee, mediaConstraints, pick(this.context, 'recordFilename'));
}
/** @private */
async connectIncoming() {
if (!this.hasStatus(CallStatus.TRYING)) {
throw new Error(`Invalid status. A call in ${CallStatus[this.status]} status can't be connected`);
}
if (this.isPolling()) {
return this.connectPollingCall();
}
await this.updateCallControl('active');
this.setStatus(CallStatus.CONNECTING);
this.wacProxy.answer(this.id, this.localMediaHandler.getCurrentMediaConstraints());
return this.waitForStatus(CallStatus.CONNECTED);
}
/**
* Create a local streams marked as localOnly stream
* @private
*/
initializeLocalMedia() {
if (bowser.msie || bowser.safari || bowser.firefox || bowser.ios) {
return Promise.resolve();
}
const mediaConstraints = this.localMediaHandler.getCurrentMediaConstraints();
return navigator.mediaDevices.getUserMedia(mediaConstraints).then((mediaStream) => {
const managedStream = new ManagedStream(mediaStream, true);
if (mediaConstraints.audio && !managedStream.hasAudioTracks()) {
throw new Error('Problem obtaining access to the microphone');
}
if (mediaConstraints.video && !managedStream.hasVideoTracks()) {
throw new Error('Problem obtaining access to the camera');
}
const oldLocalOnlyStreams = this.localOnlyStreams;
this.localOnlyStreams = this.localOnlyStreams.add(managedStream);
this.emitter.emit('localOnlyStreams', this.localOnlyStreams, oldLocalOnlyStreams);
return managedStream;
}).catch((error) => {
this.setStatus(CallStatus.DISCONNECTED, CallEndReason.MEDIA_ACCESS_ERROR);
throw error;
});
}
/** @private */
waitWhileQueued() {
return new Promise((resolve, reject) => {
this.setStatus(CallStatus.QUEUED);
let onCallUpdated = (event) => {
if (event.id !== this.wacId) {
return;
}
this.context = event.context;
this.gatewayCallee = event.gatewayUsername;
if (event.state === 'queued') {
return;
}
this.callService.emitter.off('updated', onCallUpdated);
if (event.state !== 'routed') {
this.setStatus(CallStatus.DISCONNECTED, getEndReasonFromWacValue(event.endReason));
reject(new Error('Call reached non routed state ' + event.state));
} else {
resolve();
}
};
this.callService.emitter.on('updated', onCallUpdated);
});
}
/** @private */
connectPollingCall() {
const pollingCallPromise = new Promise((resolve, reject) => {
let onPollingCallUpdate = (event) => {
if (this.wacId !== event.id) {
return;
}
if (!['finished', 'active'].includes(event.state)) {
return;
}
this.callService.emitter.off('updated', onPollingCallUpdate);
if (event.state === 'finished') {
this.setStatus(CallStatus.DISCONNECTED, getEndReasonFromWacValue(event.endReason));
return reject(new Error('Call was unexpectedly finished'));
}
resolve();
};
this.callService.emitter.on('updated', onPollingCallUpdate);
});
const gatewayCallPromise = new Promise((resolve) => {
const onRinging = (event) => {
if (event.userid !== this.gatewayCaller) {
return;
}
this.wacProxy.off('qs-ringing', onRinging);
this.id = event.callid;
resolve(this.connectIncoming());
};
this.wacProxy.on('qs-ringing', onRinging);
});
return Promise.all([
gatewayCallPromise,
pollingCallPromise,
this.callAttemptService.accept(this.wacId),
]).then(() => {
this.pollingCall = false;
});
}
/** @private */
reconnect(callId) {
this.localMediaHandler.setVideoStream();
this.remoteStreams.forEach(stream => stream.stop());
this.clearRemoteStreams();
this.setStatus(CallStatus.TRYING);
if (this.direction === CallDirection.INCOMING) {
this.id = callId;
return this.connectIncoming();
}
this.wacProxy.once('qs-calling', (event) => {
this.id = event.callid;
});
const mediaConstraints = this.localMediaHandler.getCurrentMediaConstraints();
this.wacProxy.call(this.gatewayCallee, mediaConstraints, pick(this.context, 'recordFilename'));
}
/**
* Disconnects or rejects the call
* @return {Promise<Call>}
*/
async disconnect() {
switch (this.status) {
case CallStatus.UNCONNECTED:
case CallStatus.DISCONNECTED:
throw new Error(`Invalid status. A call in ${CallStatus[this.status]} status can't be disconnected`);
case CallStatus.TRYING:
if (this.isPolling()) {
try {
await this.callAttemptService.reject(this.wacId);
} finally {
this.setStatus(CallStatus.DISCONNECTED, CallEndReason.USER_BUSY);
}
return this;
}
this.wacProxy.hangup(this.id);
await this.waitForStatus(CallStatus.DISCONNECTED);
return this;
case CallStatus.QUEUED:
try {
this.updateCallControl('finished', 'leavesqueue');
} finally {
this.setStatus(CallStatus.DISCONNECTED, CallEndReason.ORIGINATOR_CANCEL);
}
return this;
case CallStatus.CONNECTING:
await this.waitForStatus(CallStatus.CONNECTED);
return this.disconnect();
default:
this.wacProxy.hangup(this.id);
await this.waitForStatus(CallStatus.DISCONNECTED);
return this;
}
}
/**
* Holds this call
* @return {Promise}
*/
hold() {
return new Promise((resolve, reject) => {
let onSuccess, onError;
const unbind = () => {
this.wacProxy.off('qs-onhold-success', onSuccess);
this.wacProxy.off('qs-onhold-error', onError);
};
onSuccess = (event) => {
if (event.callid !== this.id) {
return;
}
unbind();
this.setStatus(CallStatus.HOLD);
resolve();
};
onError = (event) => {
if (event.callid !== this.id) {
return;
}
unbind();
reject(event.reason);
};
this.wacProxy.on('qs-onhold-success', onSuccess);
this.wacProxy.on('qs-onhold-error', onError);
this.wacProxy.hold(this.id);
});
}
/**
* Resumes this call
* @return {Promise}
*/
resume() {
return new Promise((resolve, reject) => {
let onSuccess, onError;
const unbind = () => {
this.wacProxy.off('qs-resume-success', onSuccess);
this.wacProxy.off('qs-resume-error', onError);
};
onSuccess = (event) => {
if (event.callid !== this.id) {
return;
}
unbind();
this.setStatus(CallStatus.CONNECTED);
resolve();
};
onError = (event) => {
if (event.callid !== this.id) {
return;
}
unbind();
reject(event.reason);
};
this.wacProxy.on('qs-resume-success', onSuccess);
this.wacProxy.on('qs-resume-error', onError);
this.wacProxy.resume(this.id);
});
}
/**
* @param {string} to
* @return {Promise<boolean, Error>} true if the call is correctly transferred and false if the
* transfer is rejected or can not be done
*/
async transfer(to) {
if (!this.hasStatus(CallStatus.CONNECTED, CallStatus.HOLD)) {
throw new Error(`Invalid status. A call in ${CallStatus[this.status]} status can't be transfered`);
}
const gwUsername = await this.userManager.getGatewayUsername(to);
this.wacProxy.transfer(this.getRemoteGatewayUser(), gwUsername, this.id);
await this.waitForStatus(CallStatus.REMOTE_HOLD);
const res = await new Promise((resolve) => {
let onSuccess, onError;
const unbind = () => {
this.wacProxy.off('qs-end-call', onSuccess);
this.wacProxy.off('qs-resume-success', onError);
};
onSuccess = (event) => {
if (event.callid !== this.id) {
return;
}
unbind();
resolve(true);
};
onError = (event) => {
if (event.callid !== this.id) {
return;
}
unbind();
this.setStatus(CallStatus.CONNECTED);
resolve(false);
};
this.wacProxy.on('qs-end-call', onSuccess);
this.wacProxy.on('qs-resume-success', onError);
});
if (res) {
await this.waitForStatus(CallStatus.DISCONNECTED);
}
return res;
}
/** Send a DTMF tone
* @param {string} key The key of the DTMF tone to send
*/
sendDtmf(key) {
this.wacProxy.insertDTMF(this.id, key);
}
}