MM-48000: Call started in another client creates a CurrentCall on mobile client (#6708)

* move creating/leaving currentCall into the connection; refactoring

* refactoring; two-stage connecting so existing screen stream can be saved

* better separation of concerns

* change warning to debug

* use getServerDatabase instead of getActiveServerDatabase

* fix bugs

* PR comments
This commit is contained in:
Christopher Poile 2022-11-08 14:45:10 -05:00 committed by GitHub
parent 23194034da
commit 7038a17ef4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 116 additions and 45 deletions

View file

@ -11,6 +11,8 @@ import {getConnectionForTesting} from '@calls/actions/calls';
import * as Permissions from '@calls/actions/permissions';
import * as State from '@calls/state';
import {
myselfLeftCall,
newCurrentCall,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
@ -139,7 +141,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
@ -163,7 +168,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
@ -174,6 +182,9 @@ describe('Actions.Calls', () => {
await act(async () => {
CallsActions.leaveCall();
// because disconnect is mocked
myselfLeftCall();
});
expect(disconnectMock).toBeCalled();
@ -191,7 +202,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
@ -218,7 +232,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'mysUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');

View file

@ -8,7 +8,6 @@ import {fetchUsersByIds} from '@actions/remote/user';
import {
getCallsConfig,
getCallsState,
myselfLeftCall,
setCalls,
setChannelEnabled,
setConfig,
@ -16,6 +15,8 @@ import {
setScreenShareURL,
setSpeakerPhone,
setCallForChannel,
newCurrentCall,
myselfLeftCall,
} from '@calls/state';
import {General, Preferences} from '@constants';
import Calls from '@constants/calls';
@ -218,7 +219,7 @@ export const enableChannelCalls = async (serverUrl: string, channelId: string, e
return {};
};
export const joinCall = async (serverUrl: string, channelId: string, hasMicPermission: boolean): Promise<{ error?: string | Error; data?: string }> => {
export const joinCall = async (serverUrl: string, channelId: string, userId: string, hasMicPermission: boolean): Promise<{ error?: string | Error; data?: string }> => {
// Edge case: calls was disabled when app loaded, and then enabled, but app hasn't
// reconnected its websocket since then (i.e., hasn't called batchLoadCalls yet)
const {data: enabled} = await checkIsCallsPluginEnabled(serverUrl);
@ -231,9 +232,12 @@ export const joinCall = async (serverUrl: string, channelId: string, hasMicPermi
connection = null;
}
setSpeakerphoneOn(false);
newCurrentCall(serverUrl, channelId, userId);
try {
connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL, hasMicPermission);
connection = await newConnection(serverUrl, channelId, () => {
myselfLeftCall();
}, setScreenShareURL, hasMicPermission);
} catch (error: unknown) {
await forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error: error as Error};
@ -255,7 +259,6 @@ export const leaveCall = () => {
connection = null;
}
setSpeakerphoneOn(false);
myselfLeftCall();
};
export const muteMyself = () => {

View file

@ -6,6 +6,9 @@ import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions';
import {setMicPermissionsGranted} from '@calls/state';
import {errorAlert} from '@calls/utils';
import DatabaseManager from '@database/manager';
import {getCurrentUser} from '@queries/servers/user';
import {logError} from '@utils/log';
import type {IntlShape} from 'react-intl';
@ -90,10 +93,24 @@ export const leaveAndJoinWithAlert = (
const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, intl: IntlShape) => {
const {formatMessage} = intl;
let user;
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
user = await getCurrentUser(database);
if (!user) {
// This shouldn't happen, so don't bother localizing and displaying an alert.
return;
}
} catch (error) {
logError('failed to getServerDatabaseAndOperator in doJoinCall', error);
return;
}
const hasPermission = await hasMicrophonePermission();
setMicPermissionsGranted(hasPermission);
const res = await joinCall(serverUrl, channelId, hasPermission);
const res = await joinCall(serverUrl, channelId, user.id, hasPermission);
if (res.error) {
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});
errorAlert(res.error?.toString() || seeLogs, intl);

View file

@ -34,13 +34,12 @@ const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceP
switchMap((calls) => of$(Boolean(calls[channelId]))),
distinctUntilChanged(),
);
const currentCall = observeCurrentCall();
const ccDatabase = currentCall.pipe(
const ccDatabase = observeCurrentCall().pipe(
switchMap((call) => of$(call?.serverUrl || '')),
distinctUntilChanged(),
switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
);
const ccChannelId = currentCall.pipe(
const ccChannelId = observeCurrentCall().pipe(
switchMap((call) => of$(call?.channelId)),
distinctUntilChanged(),
);
@ -58,7 +57,6 @@ const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceP
isACallInCurrentChannel,
confirmToJoin,
alreadyInCall,
currentCall,
currentCallChannelName,
limitRestrictedInfo: observeIsCallLimitRestricted(serverUrl, channelId),
};

View file

@ -60,7 +60,6 @@ export async function newConnection(
// getClient can throw an error, which will be handled by the caller.
const client = NetworkManager.getClient(serverUrl);
const credentials = await getServerCredentials(serverUrl);
const ws = new WebSocketClient(serverUrl, client.getWebSocketUrl(), credentials?.token);
@ -198,6 +197,7 @@ export async function newConnection(
InCallManager.start({media: 'audio'});
InCallManager.stopProximitySensor();
peer = new Peer(null, iceConfigs);
peer.on('signal', (data: any) => {
if (data.type === 'offer' || data.type === 'answer') {

View file

@ -8,7 +8,7 @@ import {encode} from '@msgpack/msgpack/dist';
import Calls from '@constants/calls';
import DatabaseManager from '@database/manager';
import {getCommonSystemValues} from '@queries/servers/system';
import {logError} from '@utils/log';
import {logDebug, logError} from '@utils/log';
const wsMinReconnectRetryTimeMs = 1000; // 1 second
const wsReconnectionTimeout = 30000; // 30 seconds
@ -88,9 +88,11 @@ export class WebSocketClient extends EventEmitter {
if (msg.event === 'hello') {
if (msg.data.connection_id !== this.connID) {
logDebug('calls: ws new conn id from server');
this.connID = msg.data.connection_id;
this.serverSeqNo = 0;
if (this.originalConnID === '') {
logDebug('calls: ws setting original conn id');
this.originalConnID = this.connID;
}
}
@ -99,6 +101,7 @@ export class WebSocketClient extends EventEmitter {
}
return;
} else if (!this.connID) {
logDebug('calls: ws message received while waiting for hello');
return;
}

View file

@ -6,6 +6,7 @@ import assert from 'assert';
import {act, renderHook} from '@testing-library/react-hooks';
import {
newCurrentCall,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
@ -14,7 +15,8 @@ import {
useCallsConfig,
useCallsState,
useChannelsWithCalls,
useCurrentCall, useGlobalCallsState,
useCurrentCall,
useGlobalCallsState,
} from '@calls/state';
import {
setCalls,
@ -193,6 +195,7 @@ describe('useCallsState', () => {
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
@ -251,6 +254,7 @@ describe('useCallsState', () => {
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
@ -352,6 +356,7 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
@ -455,6 +460,7 @@ describe('useCallsState', () => {
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
@ -512,6 +518,7 @@ describe('useCallsState', () => {
};
const expectedCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...newCall1,
@ -526,7 +533,10 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
// test
act(() => userJoinedCall('server1', 'channel-1', 'myUserId'));
act(() => {
newCurrentCall('server1', 'channel-1', 'myUserId');
userJoinedCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedCurrentCallState);
@ -596,6 +606,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
// test joining a call and setting url:
act(() => newCurrentCall('server1', 'channel-1', 'myUserId'));
act(() => userJoinedCall('server1', 'channel-1', 'myUserId'));
assert.deepEqual((result.current[1] as CurrentCall | null)?.screenShareURL, '');
act(() => setScreenShareURL('testUrl'));
@ -640,6 +651,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
// test
act(() => newCurrentCall('server1', 'channel-1', 'myUserId'));
act(() => userJoinedCall('server1', 'channel-1', 'myUserId'));
assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, false);
act(() => setSpeakerPhone(true));
@ -680,6 +692,7 @@ describe('useCallsState', () => {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
connected: true,
...newCall1,
};
const secondExpectedCurrentCallState: CurrentCall = {
@ -702,6 +715,7 @@ describe('useCallsState', () => {
// join call
act(() => {
setMicPermissionsGranted(false);
newCurrentCall('server1', 'channel-1', 'myUserId');
userJoinedCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], expectedCallsState);

View file

@ -13,7 +13,7 @@ import {
setCurrentCall,
setGlobalCallsState,
} from '@calls/state';
import {Call, CallsConfig, ChannelsWithCalls} from '@calls/types/calls';
import {Call, CallsConfig, ChannelsWithCalls, DefaultCall, DefaultCurrentCall} from '@calls/types/calls';
export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<Call>, enabled: Dictionary<boolean>) => {
const channelsWithCalls = Object.keys(calls).reduce(
@ -105,21 +105,13 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
participants: {...currentCall.participants, [userId]: nextCall.participants[userId]},
voiceOn,
};
setCurrentCall(nextCurrentCall);
}
// Was it me that joined the call?
if (callsState.myUserId === userId) {
setCurrentCall({
...nextCall,
participants: {...nextCall.participants},
serverUrl,
myUserId: userId,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
micPermissionsErrorDismissed: false,
});
// If this is the currentUser, that means we've connected to the call we created.
if (userId === nextCurrentCall.myUserId) {
nextCurrentCall.connected = true;
}
setCurrentCall(nextCurrentCall);
}
};
@ -173,6 +165,22 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin
setCurrentCall(nextCurrentCall);
};
export const newCurrentCall = (serverUrl: string, channelId: string, myUserId: string) => {
let existingCall: Call = DefaultCall;
const callsState = getCallsState(serverUrl);
if (callsState.calls[channelId]) {
existingCall = callsState.calls[channelId];
}
setCurrentCall({
...DefaultCurrentCall,
...existingCall,
serverUrl,
channelId,
myUserId,
});
};
export const myselfLeftCall = () => {
setCurrentCall(null);
};
@ -185,6 +193,21 @@ export const callStarted = (serverUrl: string, call: Call) => {
const nextChannelsWithCalls = {...getChannelsWithCalls(serverUrl), [call.channelId]: true};
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
// If we started a call, we will get a callStarted event with the 'official' data from the server.
// Save that in our currentCall.
const currentCall = getCurrentCall();
if (!currentCall || currentCall.channelId !== call.channelId) {
return;
}
const nextCurrentCall = {
...currentCall,
startTime: call.startTime,
threadId: call.threadId,
ownerId: call.ownerId,
};
setCurrentCall(nextCurrentCall);
};
export const callEnded = (serverUrl: string, channelId: string) => {
@ -198,10 +221,7 @@ export const callEnded = (serverUrl: string, channelId: string) => {
delete nextChannelsWithCalls[channelId];
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
const currentCall = getCurrentCall();
if (currentCall?.channelId === channelId) {
setCurrentCall(null);
}
// currentCall is set to null by the disconnect.
};
export const setUserMuted = (serverUrl: string, channelId: string, userId: string, muted: boolean) => {

View file

@ -35,22 +35,19 @@ export type Call = {
ownerId: string;
}
export const DefaultCall = {
export const DefaultCall: Call = {
participants: {} as Dictionary<CallParticipant>,
channelId: '',
startTime: 0,
screenOn: '',
threadId: '',
ownerId: '',
};
export type CurrentCall = {
export type CurrentCall = Call & {
connected: boolean;
serverUrl: string;
myUserId: string;
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
screenOn: string;
threadId: string;
screenShareURL: string;
speakerphoneOn: boolean;
voiceOn: Dictionary<boolean>;
@ -58,6 +55,7 @@ export type CurrentCall = {
}
export const DefaultCurrentCall: CurrentCall = {
connected: false,
serverUrl: '',
myUserId: '',
participants: {},
@ -65,6 +63,7 @@ export const DefaultCurrentCall: CurrentCall = {
startTime: 0,
screenOn: '',
threadId: '',
ownerId: '',
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},

View file

@ -30,7 +30,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
distinctUntilChanged(),
);
const isInACall = currentCall.pipe(
switchMap((call) => of$(Boolean(call))),
switchMap((call) => of$(Boolean(call?.connected))),
distinctUntilChanged(),
);
const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe(

View file

@ -15,7 +15,7 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables(['rootId'], ({database, rootId}: WithDatabaseArgs & {rootId: string}) => {
const isInACall = observeCurrentCall().pipe(
switchMap((call) => of$(Boolean(call))),
switchMap((call) => of$(Boolean(call?.connected))),
distinctUntilChanged(),
);