Home Reference Source

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);
	}
}