Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WDA-2386] Softphone state machine #779

Draft
wants to merge 50 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
0c24bc2
Create a new Softphone object with state machine
manuquentin Jun 25, 2024
8d1fa5b
Add some e2e tests on softphone
manuquentin Jun 26, 2024
d1ddd3d
Add call state machine
manuquentin Jun 27, 2024
c26b8e3
Move more methods to webRtc utils
manuquentin Jun 28, 2024
030f534
Migrate more methods to softphone and call
manuquentin Jul 3, 2024
3256e9d
Add more methods in softphone and call
manuquentin Jul 4, 2024
4337454
Add missing resume method in Call
manuquentin Jul 24, 2024
b2be0fa
Add more unit tests on Call state machine
manuquentin Jul 24, 2024
7510948
1.0.0-alpha.0
manuquentin Jul 24, 2024
ba1553f
Fix getMediaConfiguration and expose state exceptions
manuquentin Jul 25, 2024
08938b2
1.0.0-alpha.1
manuquentin Jul 25, 2024
b71b279
Move more methods into softphone and call ojects
manuquentin Jul 26, 2024
7ebb655
1.0.0-alpha.2
manuquentin Jul 26, 2024
d6b0f4f
Add more methods to softphone and call
manuquentin Aug 1, 2024
dc07e19
1.0.0-alpha.3
manuquentin Aug 5, 2024
12baf85
Minor improvement in new SDK
manuquentin Aug 6, 2024
fbf6e18
1.0.0-alpha.4
manuquentin Aug 6, 2024
e408b3a
Fix shouldAutoAnswer when no request available
manuquentin Aug 6, 2024
fd174e1
1.0.0-alpha.5
manuquentin Aug 6, 2024
0d2598d
Fix sipCallId in new voice API
manuquentin Aug 8, 2024
33e7d3a
1.0.0-alpha.6
manuquentin Aug 8, 2024
2a0da62
Fix call state after making a call
manuquentin Aug 8, 2024
f85710d
1.0.0-alpha.7
manuquentin Aug 8, 2024
73f69b3
Improve softphone state machine flow
manuquentin Aug 9, 2024
1d8fa6b
1.0.0-alpha.8
manuquentin Aug 9, 2024
76e029a
Bind call event after call is made
manuquentin Aug 22, 2024
77752ce
1.0.0-alpha.9
manuquentin Aug 26, 2024
e7551ef
Fix rebase
manuquentin Aug 26, 2024
0d88caf
Handle code review
manuquentin Aug 26, 2024
a27daaf
Improve voice and softphone API
manuquentin Aug 28, 2024
e2135c3
1.0.0-alpha.10
manuquentin Aug 28, 2024
18fe06a
Fix call.isOutgoing
manuquentin Aug 28, 2024
cda4a93
1.0.0-alpha.11
manuquentin Aug 28, 2024
4b96560
Update sessionWantsToDoVideo by searching in constraint when sdp is n…
manuquentin Aug 29, 2024
90fdcfe
1.0.0-alpha.12
manuquentin Aug 29, 2024
c8e2828
Call register instead of attemptReconnection when trying to reconnect…
manuquentin Sep 4, 2024
3587ebf
1.0.0-alpha.13
manuquentin Sep 4, 2024
784986d
Add recordingPaused in call
manuquentin Sep 4, 2024
b27a342
Allow to update certain Call attribute from another one
manuquentin Sep 5, 2024
63bf339
Minor fixes on Voice.Call
manuquentin Sep 10, 2024
a414202
1.0.0-alpha.16
manuquentin Sep 10, 2024
048c15f
Do not update state in call.updateFrom
manuquentin Sep 10, 2024
4b75386
1.0.0-alpha.17
manuquentin Sep 10, 2024
3d2822a
Fix state machine for mute/hold state
manuquentin Sep 10, 2024
172e14a
1.0.0-alpha.18
manuquentin Sep 10, 2024
a4136b2
Fix isVideoMuted when no sdh
manuquentin Sep 10, 2024
92ee1a8
1.0.0-alpha.19
manuquentin Sep 10, 2024
43464f7
Backport fix on getSipCallId
manuquentin Sep 18, 2024
f82e747
Improve unregister flow
manuquentin Sep 27, 2024
3fcd544
1.0.0-alpha.20
manuquentin Sep 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wazo/sdk",
"version": "0.43.9",
"version": "1.0.0-alpha.20",
"description": "Wazo's JavaScript Software Development Kit.",
"main": "index.js",
"types": "lib/types/index.d.ts",
Expand Down Expand Up @@ -59,7 +59,8 @@
"reconnecting-websocket": "^4.4.0",
"sdp-transform": "^2.14.2",
"sip.js": "^0.21.2",
"webrtc-adapter": "^8.2.3"
"webrtc-adapter": "^8.2.3",
"xstate": "^5.13.2"
},
"devDependencies": {
"@babel/core": "^7.24.4",
Expand Down
193 changes: 193 additions & 0 deletions src/__tests__/__voice__/call.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/* eslint-disable no-underscore-dangle */
import { Inviter, UserAgent, type URI } from 'sip.js';

import softphone from '../../voice/softphone';
import Call from '../../voice/call';
import { Actions, EstablishedStates, EstablishedSubStates, States } from '../../state-machine/call-state-machine';
import { Actions as SoftphoneActions } from '../../state-machine/softphone-state-machine';
import ApiCall from '../../domain/Call';

import InvalidStateTransition from '../../domain/InvalidStateTransition';

const uaOptions = {
logBuiltinEnabled: false,
transportOptions: {
traceSip: false,
wsServers: 'ws://localhost',
},
};

jest.mock('../..', () => ({
__esModule: true,
default: {
Auth: {
getHost: jest.fn(() => 'my-stack.io'),
getSession: jest.fn(() => ({
displayName: () => 'John Doe',
primaryWebRtcLine: () => ({}),
})),
},
},
}));

jest.mock('../../web-rtc-client', () => jest.fn().mockImplementation(() => ({
answer: jest.fn(() => Promise.resolve()),
mute: jest.fn(() => Promise.resolve()),
hold: jest.fn(() => Promise.resolve()),
unhold: jest.fn(() => Promise.resolve()),
hangup: jest.fn(() => Promise.resolve()),
register: jest.fn(() => Promise.resolve()),
onCallEnded: jest.fn(() => Promise.resolve()),
setOnHeartbeatTimeout: jest.fn(() => Promise.resolve()),
setOnHeartbeatCallback: jest.fn(() => Promise.resolve()),
on: jest.fn(),
})));

const ua = new UserAgent(uaOptions);
const sipCall = new Inviter(ua, UserAgent.makeURI('sip:1234@my.stack.io') as URI);

softphone.connect();
// @ts-ignore: private method
softphone._sendAction(SoftphoneActions.REGISTER_DONE);

const invalidStateTransition = (state: string, action: string) => new InvalidStateTransition(`Invalid state transition from ${state} with action ${action}`, state, action);

describe('Call', () => {
describe('parsing an API call', () => {
it('Should parse an API call and transform it to a Call instance', async () => {
const apiCall = new ApiCall({
id: '1234',
sipCallId: 'abcd',
isCaller: false,
isVideo: true,
recording: true,
callerName: '',
callerNumber: '',
calleeName: '',
calleeNumber: '0123423223',
dialedExtension: '',
lineId: null,
muted: true,
onHold: false,
startingTime: new Date(),
status: 'Up',
talkingToIds: [],
});
const call = Call.parseCall(apiCall);

expect(call.recording).toBeTruthy();
expect(call.id).toBe(apiCall.sipCallId);
expect(call.apiId).toBe(apiCall.id);
expect(call.number).toBe(apiCall.calleeNumber);
expect(call.answerTime).toStrictEqual(undefined);
expect(call.creationTime).toStrictEqual(apiCall.startingTime);
expect(call.isMuted()).toBeTruthy();
expect(call.state).toStrictEqual({
[States.ESTABLISHED]: {
[EstablishedStates.ONGOING]: {},
[EstablishedSubStates.MUTE]: EstablishedStates.MUTED,
[EstablishedSubStates.HOLD]: EstablishedStates.UN_HELD,
},
});
expect(call.isEstablished()).toBeTruthy();
});
});

describe('accept', () => {
it('Should throw an error when accepting a not ringing call', async () => {
const call = new Call(sipCall, softphone);

await expect(call.accept()).rejects.toThrowError(invalidStateTransition(States.IDLE, Actions.ACCEPT));
});

it('Should throw an error when the softphone is not registered', async () => {
const call = new Call(sipCall, softphone);
// @ts-ignore: private method
call._sendAction(Actions.INCOMING_CALL);
// @ts-ignore: private method
softphone._sendAction(SoftphoneActions.TRANSPORT_CLOSED);

await expect(call.accept()).rejects.toThrowError(invalidStateTransition(States.RINGING, Actions.ACCEPT));

// Revert softphone state
softphone.connect();
// @ts-ignore: private method
softphone._sendAction(SoftphoneActions.REGISTER_DONE);
});

it('Should not throw an error when accepting a not ringing call', async () => {
const call = new Call(sipCall, softphone);
// @ts-ignore: private method
call._sendAction(Actions.INCOMING_CALL);

await call.accept();
expect(softphone.client.answer).toHaveBeenCalled();
});
});

describe('hangup', () => {
it('Should throw an error when terminating a non established call', async () => {
const call = new Call(sipCall, softphone);

await expect(call.hangup()).rejects.toThrowError(invalidStateTransition(States.IDLE, Actions.HANGUP));
});

it('Should not throw an error when terminating an established call', async () => {
const call = new Call(sipCall, softphone);
// @ts-ignore: private method
call._sendAction(Actions.MAKE_CALL);
// @ts-ignore: private method
call._sendAction(Actions.REMOTLY_ACCEPTED);

await call.hangup();
expect(softphone.client.hangup).toHaveBeenCalled();
});
});

describe('state propagation', () => {
it('Should update the call state in events', (done) => {
const call = new Call(sipCall, softphone);
// @ts-ignore: private method
call._sendAction(Actions.MAKE_CALL);

call.on('remotelyAccepted', () => {
expect(call.state).toStrictEqual({
[States.ESTABLISHED]: {
[EstablishedStates.ONGOING]: {},
[EstablishedSubStates.MUTE]: EstablishedStates.UN_MUTED,
[EstablishedSubStates.HOLD]: EstablishedStates.UN_HELD,
},
});
done();
});

call.onAccepted();
});
});

describe('Multiple sub state', () => {
it('Should mute and hold a call', () => {
const call = new Call(sipCall, softphone);
// @ts-ignore: private method
call._sendAction(Actions.MAKE_CALL);

expect(call.isMuted()).toBeFalsy();
expect(call.isHeld()).toBeFalsy();

// @ts-ignore: private method
call._sendAction(Actions.REMOTLY_ACCEPTED);

call.mute();
expect(call.isMuted()).toBeTruthy();
expect(call.isHeld()).toBeFalsy();

call.hold();
expect(call.isMuted()).toBeTruthy();
expect(call.isHeld()).toBeTruthy();

call.resume();
expect(call.isMuted()).toBeTruthy();
expect(call.isHeld()).toBeFalsy();
});
});
});
82 changes: 82 additions & 0 deletions src/__tests__/__voice__/softphone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Softphone } from '../../voice/softphone';
import { Actions, States } from '../../state-machine/softphone-state-machine';

import Wazo from '../..';
import InvalidStateTransition from '../../domain/InvalidStateTransition';

const server = 'my.stack.io';
const displayName = 'John Doe';
const webRtcLine = {};
const session = {
uuid: '1234',
displayName: jest.fn(() => displayName),
primaryWebRtcLine: jest.fn(() => webRtcLine),
};

jest.mock('../..', () => ({
__esModule: true,
default: {
Auth: {
getHost: jest.fn(() => server),
getSession: jest.fn(() => session),
},
},
}));

jest.mock('../../web-rtc-client', () => jest.fn().mockImplementation(() => ({
register: jest.fn(() => Promise.resolve()),
unregister: jest.fn(() => Promise.resolve()),
setOnHeartbeatTimeout: jest.fn(() => Promise.resolve()),
setOnHeartbeatCallback: jest.fn(() => Promise.resolve()),
on: jest.fn(),
INVITE: 'invite',
})));

const invalidStateTransition = (state: string, action: string) => new InvalidStateTransition(`Invalid state transition from ${state} with action ${action}`, state, action);

describe('Softphone', () => {
describe('connect', () => {
it('Should retrieve information from Wazo.Auth', () => {
const instance = new Softphone();
jest.spyOn(instance, 'connectWithCredentials');
instance.connect({});

expect(Wazo.Auth.getHost).toHaveBeenCalled();
expect(Wazo.Auth.getSession).toHaveBeenCalled();
expect(instance.connectWithCredentials).toHaveBeenCalledWith(server, webRtcLine, displayName, { userUuid: session.uuid });
expect(instance.client.on).toHaveBeenCalledWith('invite', expect.anything());
});

it('Should throw an error when already connected', async () => {
const instance = new Softphone();
instance.softphoneActor.send({ type: Actions.REGISTER });

await expect(instance.connect({})).rejects.toThrowError(invalidStateTransition(States.REGISTERING, Actions.REGISTER));
});

it('Should not allow to register twice', async () => {
const instance = new Softphone();
instance.connect({});

await expect(instance.connect({})).rejects.toThrowError(invalidStateTransition(States.REGISTERING, Actions.REGISTER));
});
});

describe('disconnect', () => {
it('Should throw an error when not connected', async () => {
const instance = new Softphone();

await expect(instance.disconnect()).rejects.toThrowError(invalidStateTransition(States.UNREGISTERED, Actions.UNREGISTER));
});

it('Should unregister the client', async () => {
const instance = new Softphone();
instance.connect({});
instance.softphoneActor.send({ type: Actions.REGISTER_DONE });

await instance.disconnect();

expect(instance.client.unregister).toHaveBeenCalled();
});
});
});
4 changes: 2 additions & 2 deletions src/checker/checks/webrtc-transport.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import WebRTCClient from '../../web-rtc-client';
import { WazoSession } from '../../domain/types';
import { SipCall } from '../../domain/types';

export default {
name: 'WebRTC Transport (WS) ~30s',
check: (server: string, session: WazoSession): Promise<void> => new Promise((resolve, reject) => {
check: (server: string, session: SipCall): Promise<void> => new Promise((resolve, reject) => {
const [host, port = 443] = server.split(':');

const client = new WebRTCClient({
Expand Down
3 changes: 2 additions & 1 deletion src/checker/checks/webrtc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* global MediaStream */
import { getSipCallId } from '../../utils/sdp';
import WebRTCClient from '../../web-rtc-client';

export default {
Expand Down Expand Up @@ -35,7 +36,7 @@ export default {
client.on(client.REGISTERED, () => {
const sipSession = client.call('*10');

if (!sipSession || !client.getSipSessionId(sipSession)) {
if (!sipSession || !getSipCallId(sipSession)) {
return handleError('Unable to make call through WebRTC');
}

Expand Down
6 changes: 3 additions & 3 deletions src/domain/CallSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SessionState } from 'sip.js/lib/api/session-state';
import Call from './Call';
import newFrom from '../utils/new-from';
import updateFrom from '../utils/update-from';
import { WazoSession } from './types';
import { SipCall } from './types';

type CallSessionArguments = {
answered: boolean;
Expand Down Expand Up @@ -32,7 +32,7 @@ type CallSessionArguments = {
screensharing: boolean;
recording: boolean;
recordingPaused: boolean;
sipSession?: WazoSession;
sipSession?: SipCall;
conference: boolean;
};
export default class CallSession {
Expand Down Expand Up @@ -96,7 +96,7 @@ export default class CallSession {

recordingPaused: boolean;

sipSession: WazoSession | undefined;
sipSession: SipCall | undefined;

conference: boolean;

Expand Down
13 changes: 13 additions & 0 deletions src/domain/InvalidState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class InvalidState extends Error {

state: string;

constructor(message: string, state: string) {
super(message);

this.state = state;
}

}

export default InvalidState;
17 changes: 17 additions & 0 deletions src/domain/InvalidStateTransition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class InvalidStateTransition extends Error {

state: string;

action: string;

constructor(message: string, action: string, state: string) {
super(message);

this.action = action;

this.state = state;
}

}

export default InvalidStateTransition;
Loading