Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
<!-- permissions related to jitsi call -->
<uses-permission android:name="android.permission.BLUETOOTH" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> -->
<!-- <uses-permission android:name="android.permission.CALL_PHONE" /> -->
<uses-feature android:name="android.hardware.audio.output" />
<uses-feature android:name="android.hardware.microphone" />

<!-- android 13 notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Expand Down Expand Up @@ -87,6 +98,19 @@
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${BugsnagAPIKey}" />

<service android:name="io.wazo.callkeep.VoiceConnectionService"
android:label="Wazo"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true"
android:foregroundServiceType="microphone"
>
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>

<service android:name="io.wazo.callkeep.RNCallKeepBackgroundMessagingService" />
</application>

<queries>
Expand Down
5 changes: 5 additions & 0 deletions app/definitions/Voip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type IceServer = {
urls: string;
username?: string;
credential?: string;
};
6 changes: 6 additions & 0 deletions app/lib/constants/defaultSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,5 +300,11 @@ export const defaultSettings = {
Cloud_Workspace_AirGapped_Restrictions_Remaining_Days: {
type: 'valueAsNumber'
},
VoIP_TeamCollab_Ice_Servers: {
type: 'valueAsString'
},
VoIP_TeamCollab_Ice_Gathering_Timeout: {
type: 'valueAsNumber'
},
...deprecatedSettings
} as const;
21 changes: 21 additions & 0 deletions app/lib/services/voip/MediaCallLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { IMediaSignalLogger } from '@rocket.chat/media-signaling';

export class MediaCallLogger implements IMediaSignalLogger {
log(...args: unknown[]): void {
console.log(`[Media Call] ${JSON.stringify(args)}`);
}

debug(...args: unknown[]): void {
if (__DEV__) {
console.log(`[Media Call Debug] ${JSON.stringify(args)}`);
}
}

error(...args: unknown[]): void {
console.log(`[Media Call Error] ${JSON.stringify(args)}`);
}

warn(...args: unknown[]): void {
console.log(`[Media Call Warning] ${JSON.stringify(args)}`);
}
}
159 changes: 159 additions & 0 deletions app/lib/services/voip/MediaSessionInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import {
ClientMediaSignal,
IClientMediaCall,
MediaCallWebRTCProcessor,
MediaSignalingSession,
WebRTCProcessorConfig
} from '@rocket.chat/media-signaling';
import RNCallKeep from 'react-native-callkeep';
import { registerGlobals } from 'react-native-webrtc';

import { mediaSessionStore } from './MediaSessionStore';
import { store } from '../../store/auxStore';
import sdk from '../sdk';
import { parseStringToIceServers } from './parseStringToIceServers';
import { IceServer } from '../../../definitions/Voip';
import { IDDPMessage } from '../../../definitions/IDDPMessage';

class MediaSessionInstance {
private iceServers: IceServer[] = [];
private iceGatheringTimeout: number = 5000;
private mediaSignalListener: { stop: () => void } | null = null;
private mediaSignalsListener: { stop: () => void } | null = null;
private instance: MediaSignalingSession | null = null;
private storeTimeoutUnsubscribe: (() => void) | null = null;
private storeIceServersUnsubscribe: (() => void) | null = null;

public init(userId: string): void {
this.stop();
registerGlobals();
this.configureRNCallKeep();
this.configureIceServers();

mediaSessionStore.setWebRTCProcessorFactory(
(config: WebRTCProcessorConfig) =>
new MediaCallWebRTCProcessor({
...config,
rtc: { ...config.rtc, iceServers: this.iceServers },
iceGatheringTimeout: this.iceGatheringTimeout
})
);
mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => {
sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal));
});
this.instance = mediaSessionStore.getInstance(userId);
mediaSessionStore.onChange(() => (this.instance = mediaSessionStore.getInstance(userId)));

this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: IDDPMessage) => {
if (!this.instance) {
return;
}
const [, ev] = ddpMessage.fields.eventName.split('/');
if (ev !== 'media-signal') {
return;
}
const signal = ddpMessage.fields.args[0];
this.instance.processSignal(signal);
});

this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => {
if (call && !call.hidden) {
call.emitter.on('stateChange', oldState => {
console.log(`📊 ${oldState} → ${call.state}`);
});

const displayName = call.contact.displayName || call.contact.username || 'Unknown';
RNCallKeep.displayIncomingCall(call.callId, displayName, displayName, 'generic', false);

call.emitter.on('ended', () => RNCallKeep.endCall(call.callId));
}
});
}

private configureRNCallKeep() {
RNCallKeep.addEventListener('answerCall', async ({ callUUID }) => {
const mainCall = this.instance?.getMainCall();
if (mainCall && mainCall.callId === callUUID) {
await mainCall.accept();
RNCallKeep.setCurrentCallActive(mainCall.callId);
} else {
RNCallKeep.endCall(callUUID);
}
});

RNCallKeep.addEventListener('endCall', ({ callUUID }) => {
const mainCall = this.instance?.getMainCall();
if (mainCall && mainCall.callId === callUUID) {
if (mainCall.state === 'ringing') {
mainCall.reject();
} else {
mainCall.hangup();
}
}
});

RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => {
const mainCall = this.instance?.getMainCall();
if (mainCall && mainCall.callId === callUUID) {
mainCall.setMuted(muted);
}
});

RNCallKeep.addEventListener('didPerformDTMFAction', ({ digits }) => {
const mainCall = this.instance?.getMainCall();
if (mainCall) {
mainCall.sendDTMF(digits);
}
});
}

private getIceServers() {
const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any;
return parseStringToIceServers(iceServers);
}

private configureIceServers() {
this.iceServers = this.getIceServers();
this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number;

this.storeTimeoutUnsubscribe = store.subscribe(() => {
const currentTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number;
if (currentTimeout !== this.iceGatheringTimeout) {
this.iceGatheringTimeout = currentTimeout;
this.instance?.setIceGatheringTimeout(this.iceGatheringTimeout);
}
});

this.storeIceServersUnsubscribe = store.subscribe(() => {
const currentIceServers = this.getIceServers();
if (currentIceServers !== this.iceServers) {
this.iceServers = currentIceServers;
this.instance?.setIceServers(this.iceServers);
}
});
}

private stop() {
if (this.mediaSignalListener) {
this.mediaSignalListener.stop();
}
if (this.mediaSignalsListener) {
this.mediaSignalsListener.stop();
}
RNCallKeep.removeEventListener('answerCall');
RNCallKeep.removeEventListener('endCall');
RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
RNCallKeep.removeEventListener('didPerformDTMFAction');
if (this.storeTimeoutUnsubscribe) {
this.storeTimeoutUnsubscribe();
}
if (this.storeIceServersUnsubscribe) {
this.storeIceServersUnsubscribe();
}
if (this.instance) {
this.instance.endSession();
}
}
}

export const mediaSessionInstance = new MediaSessionInstance();
100 changes: 100 additions & 0 deletions app/lib/services/voip/MediaSessionStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Emitter } from '@rocket.chat/emitter';
import { MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling';
import type { MediaSignalTransport, ClientMediaSignal, WebRTCProcessorConfig } from '@rocket.chat/media-signaling';
import { mediaDevices } from 'react-native-webrtc';
import BackgroundTimer from 'react-native-background-timer';

import { MediaCallLogger } from './MediaCallLogger';

type SignalTransport = MediaSignalTransport<ClientMediaSignal>;

const randomStringFactory = (): string =>
Date.now().toString(36) + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);

class MediaSessionStore extends Emitter<{ change: void }> {
private sessionInstance: MediaSignalingSession | null = null;
private sendSignalFn: SignalTransport | null = null;
private _webrtcProcessorFactory: ((config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor) | null = null;

private change() {
this.emit('change');
}

public onChange(callback: () => void) {
return this.on('change', callback);
}

private webrtcProcessorFactory(config: WebRTCProcessorConfig): MediaCallWebRTCProcessor {
if (!this._webrtcProcessorFactory) {
throw new Error('WebRTC processor factory not set');
}
return this._webrtcProcessorFactory(config);
}

private sendSignal(signal: ClientMediaSignal) {
if (!this.sendSignalFn) {
throw new Error('Send signal function not set');
}
return this.sendSignalFn(signal);
}

private makeInstance(userId: string): MediaSignalingSession | null {
if (this.sessionInstance !== null) {
this.sessionInstance.endSession();
this.sessionInstance = null;
}

if (!this._webrtcProcessorFactory || !this.sendSignalFn) {
throw new Error('WebRTC processor factory and send signal function must be set');
}

this.sessionInstance = new MediaSignalingSession({
userId,
transport: (signal: ClientMediaSignal) => this.sendSignal(signal),
processorFactories: {
webrtc: (config: WebRTCProcessorConfig) => this.webrtcProcessorFactory(config)
},
mediaStreamFactory: (constraints: any) => mediaDevices.getUserMedia(constraints) as unknown as Promise<MediaStream>,
randomStringFactory,
logger: new MediaCallLogger(),
timerProcessor: {
setInterval: (callback: () => void, interval: number) => BackgroundTimer.setInterval(callback, interval),
clearInterval: (interval: number) => BackgroundTimer.clearInterval(interval),
setTimeout: (callback: () => void, timeout: number) => BackgroundTimer.setTimeout(callback, timeout),
clearTimeout: (timeout: number) => BackgroundTimer.clearTimeout(timeout)
}
});

this.change();
return this.sessionInstance;
}

public getInstance(userId?: string): MediaSignalingSession | null {
if (!userId) {
throw new Error('User Id is required');
}

if (this.sessionInstance?.userId === userId) {
return this.sessionInstance;
}

return this.makeInstance(userId);
}

public setSendSignalFn(sendSignalFn: SignalTransport) {
this.sendSignalFn = sendSignalFn;
this.change();
}

public setWebRTCProcessorFactory(factory: (config: WebRTCProcessorConfig) => MediaCallWebRTCProcessor): void {
this._webrtcProcessorFactory = factory;
this.change();
}

public getCurrentInstance(): MediaSignalingSession | null {
return this.sessionInstance;
}
}

// TODO: change name
export const mediaSessionStore = new MediaSessionStore();
24 changes: 24 additions & 0 deletions app/lib/services/voip/parseStringToIceServers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { IceServer } from '../../../definitions/Voip';

export const parseStringToIceServer = (server: string): IceServer => {
const credentials = server.trim().split('@');
const urls = credentials.pop() as string;
const [username, credential] = credentials.length === 1 ? credentials[0].split(':') : [];

return {
urls,
...(username &&
credential && {
username: decodeURIComponent(username),
credential: decodeURIComponent(credential)
})
};
};

export const parseStringToIceServers = (string: string): IceServer[] => {
if (!string) {
return [];
}
const lines = string.trim() ? string.split(',') : [];
return lines.map(line => parseStringToIceServer(line));
};
4 changes: 2 additions & 2 deletions app/lib/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ if (__DEV__) {
applyAppStateMiddleware(),
applyInternetStateMiddleware(),
applyMiddleware(reduxImmutableStateInvariant),
applyMiddleware(sagaMiddleware),
applyMiddleware(logger)
applyMiddleware(sagaMiddleware)
// applyMiddleware(logger)
);
} else {
sagaMiddleware = createSagaMiddleware();
Expand Down
Loading
Loading