Skip to content
Merged
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
5 changes: 5 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,11 @@ var config = {
// (default: disabled).
// enableIceRestart: false,

// Whether an in-place ICE restart is requested proactively when the device changes network (mobile only),
// instead of waiting for ICE to fail. Only has an effect when 'enableIceRestart' is enabled
// (default: enabled).
// enableIceRestartOnNetworkChange: true,

// Use TURN/UDP servers for the jitsi-videobridge connection (by default
// we filter out TURN/UDP because it is usually not needed since the
// bridge itself is reachable via UDP)
Expand Down
28 changes: 27 additions & 1 deletion react/features/base/conference/middleware.native.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import { appNavigate } from '../../app/actions.native';
import { notifyConferenceFailed } from '../../conference/actions.native';
import { JitsiConferenceErrors } from '../lib-jitsi-meet';
import { SET_NETWORK_INFO } from '../net-info/actionTypes';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';

import { CONFERENCE_FAILED } from './actionTypes';
import { CONFERENCE_FAILED, CONFERENCE_JOINED, CONFERENCE_LEFT } from './actionTypes';
import { conferenceLeft } from './actions.native';
import { TRIGGER_READY_TO_CLOSE_REASONS } from './constants';
import './middleware.any';
import { processDestroyConferenceEvent } from './functions';
import {
maybeRestartIceOnNetworkChange,
noteSessionNetwork,
resetNetworkChangeState
} from './networkChangeIceRestart.native';

MiddlewareRegistry.register(store => next => action => {
const { dispatch } = store;
const { error } = action;

switch (action.type) {
case CONFERENCE_JOINED: {
const result = next(action);

noteSessionNetwork(store);

return result;
}

case CONFERENCE_LEFT:
resetNetworkChangeState();
break;

case SET_NETWORK_INFO: {
const result = next(action);

maybeRestartIceOnNetworkChange(store);

return result;
}

case CONFERENCE_FAILED: {
const { getState } = store;
const state = getState();
Expand Down
238 changes: 238 additions & 0 deletions react/features/base/conference/networkChangeIceRestart.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { IStore } from '../../app/types';
import { JitsiIceRestartReason } from '../lib-jitsi-meet';
import { INetInfoState } from '../net-info/reducer';

import { getCurrentConference } from './functions';
import logger from './logger';

/**
* How long the network state has to stay put before a change is acted upon. A real interface handover produces a
* burst of events from the OS (wifi may drop to 'none' and back before settling on cellular), and reacting to each
* one would request several restarts for a single physical event.
*/
export const NETWORK_CHANGE_DEBOUNCE_MS = 3000;

/**
* The minimum time between two ICE restarts triggered by a network change. Jicofo rate-limits the requests server
* side as well; this keeps a flapping radio from burning through that budget.
*/
export const NETWORK_CHANGE_COOLDOWN_MS = 30000;

/**
* A tag used to make the log lines of this module greppable together with the ones the bridge and Jicofo emit.
*/
const LOG_PREFIX = 'ICE restart (network change):';

/**
* The identity of the network the current JVB ICE session was established on, as returned by
* {@link getNetworkIdentity}. A restart is requested when the device's current network no longer matches it.
*/
let sessionNetworkIdentity: string | undefined;

/**
* Set when a SET_NETWORK_INFO update reports the device offline while a session is active. Consumed (and
* cleared) the next time the device is back online. A reconnect is treated as a change even if it lands back
* on the exact same address (e.g. the same DHCP lease) - the interface genuinely went down and came back up,
* so any ICE candidates gathered on it are stale regardless of whether the address string still matches.
*/
let hadOfflineGap = false;

/**
* The pending debounce timer, if any.
*/
let debounceTimer: ReturnType<typeof setTimeout> | undefined;

/**
* When the last restart was requested, used to enforce {@link NETWORK_CHANGE_COOLDOWN_MS}.
*/
let lastRequestTime = 0;

/**
* Derives a stable identifier for the network the device is currently attached to. Two values comparing unequal
* mean the local address the media session was set up on is no longer usable, so the session's local ICE
* candidates are stale.
*
* The network type alone is not enough: moving between two wifi networks (leaving home for the office) keeps the
* type at 'wifi' while changing the local address, so the address is included when the OS reports one. It is
* reported for wifi and ethernet but not for cellular, so two different cellular attachments are
* indistinguishable here and will not be detected as a change.
*
* @param {INetInfoState} netInfo - The 'base/net-info' state.
* @returns {string|undefined} - The identity, or undefined if the network type is not known yet.
*/
export function getNetworkIdentity(netInfo?: INetInfoState): string | undefined {
const networkType = netInfo?.networkType;

if (!networkType) {
return undefined;
}

const ipAddress = netInfo?.details?.ipAddress;

return ipAddress ? `${networkType}/${ipAddress}` : networkType;
}

/**
* Records the network the JVB ICE session has just been established on, so that later changes can be detected
* relative to it. Called when a conference is joined and after a restart has been requested.
*
* @param {IStore} store - The redux store.
* @returns {void}
*/
export function noteSessionNetwork(store: IStore): void {
sessionNetworkIdentity = getNetworkIdentity(store.getState()['features/base/net-info']);

logger.debug(`${LOG_PREFIX} session network is ${sessionNetworkIdentity}`);
}

/**
* Drops any pending work and forgets the session's network. Called when the conference goes away.
*
* @returns {void}
*/
export function resetNetworkChangeState(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = undefined;
}

sessionNetworkIdentity = undefined;
hadOfflineGap = false;
}

/**
* Requests the restart, if all of the conditions still hold. Everything is re-read from the store here rather than
* captured when the timer was armed, so that a network which changed and changed back within the debounce window
* does not produce a pointless restart.
*
* @param {IStore} store - The redux store.
* @private
* @returns {void}
*/
function _restartIce(store: IStore): void {
const state = store.getState();
const netInfo = state['features/base/net-info'];
const identity = getNetworkIdentity(netInfo);
const recoveringFromOfflineGap = hadOfflineGap;

if (!identity || (!recoveringFromOfflineGap && identity === sessionNetworkIdentity)) {
logger.debug(`${LOG_PREFIX} network settled back to ${sessionNetworkIdentity}, nothing to do`);

return;
}

// Going offline is not something a restart can recover from - there is no interface to move to. The next
// change, when the device attaches to something, is what we want to act on.
if (!netInfo?.isOnline) {
logger.info(`${LOG_PREFIX} offline (${identity}), not requesting a restart`);

hadOfflineGap = true;

return;
}

hadOfflineGap = false;

const conference = getCurrentConference(state);

if (!conference) {
return;
}

if (!conference.isIceRestartSupported()) {
return;
}

// While P2P is active the JVB session is on hold and not carrying media, so there is nothing to preserve.
if (conference.isP2PActive()) {
logger.info(`${LOG_PREFIX} P2P is active, not requesting a restart`);

return;
}

const sinceLastRequest = Date.now() - lastRequestTime;

if (sinceLastRequest < NETWORK_CHANGE_COOLDOWN_MS) {
logger.warn(`${LOG_PREFIX} suppressed, last request was ${sinceLastRequest}ms ago`);

return;
}

logger.info(`${LOG_PREFIX} requesting a restart, ${sessionNetworkIdentity} -> ${identity}`);

// Set before the request so that a second change arriving while this one is in flight does not request
// another restart.
lastRequestTime = Date.now();

conference.restartJvbIce(JitsiIceRestartReason.NETWORK_CHANGE)
.then(() => {
// The request was accepted; the bridge's new transport arrives asynchronously. Treat the new network
// as the session's own from here so a repeat of the same change is not acted upon again.
noteSessionNetwork(store);
})
.catch((error: Error) => {
// Not fatal and deliberately not escalated: the pre-existing recovery flow (a full session restart
// once ICE actually fails) is untouched, and until then media keeps flowing on the old path.
logger.warn(`${LOG_PREFIX} request failed: ${error?.message ?? error}`);
});
}

/**
* Reacts to a change of the device's network. An in-place ICE restart is make-before-break - the bridge keeps
* carrying media on the established path while the new one is brought up - so it is safe to request whenever the
* local address the session was set up on may have gone stale, rather than only once ICE has failed.
*
* The work is debounced; see {@link NETWORK_CHANGE_DEBOUNCE_MS}.
*
* @param {IStore} store - The redux store.
* @returns {void}
*/
export function maybeRestartIceOnNetworkChange(store: IStore): void {
const state = store.getState();

// No session to move.
if (!getCurrentConference(state)) {
return;
}

const netInfo = state['features/base/net-info'];
const identity = getNetworkIdentity(netInfo);

if (!identity) {
return;
}

// The OS had not reported a network yet when the conference was joined, so this is the first chance to learn
// which network the session was established on.
if (sessionNetworkIdentity === undefined) {
sessionNetworkIdentity = identity;

return;
}

if (!netInfo?.isOnline) {
hadOfflineGap = true;

return;
}

// Still on the network the session was established on, and no intervening offline gap to recover from.
if (identity === sessionNetworkIdentity && !hadOfflineGap) {
return;
}

const { enableIceRestartOnNetworkChange = true } = state['features/base/config'];

if (!enableIceRestartOnNetworkChange) {
return;
}

logger.debug(`${LOG_PREFIX} ${sessionNetworkIdentity} -> ${identity}, settling for `
+ `${NETWORK_CHANGE_DEBOUNCE_MS}ms`);

debounceTimer && clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = undefined;
_restartIce(store);
}, NETWORK_CHANGE_DEBOUNCE_MS);
}
3 changes: 3 additions & 0 deletions react/features/base/conference/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export interface IJitsiConference {
isE2EEEnabled: Function;
isE2EESupported: Function;
isEndConferenceSupported: Function;
isIceRestartSupported: Function;
isLobbySupported: Function;
isP2PActive: Function;
isSIPCallingSupported: Function;
Expand All @@ -158,6 +159,8 @@ export interface IJitsiConference {
options: any;
removeTrack: Function;
replaceTrack: Function;
// Keep in sync with lib-jitsi-meet's IceRestartReason (service/RTC/IceRestartReason.ts).
restartJvbIce: (reason?: 'api' | 'ice-failed' | 'network-change') => Promise<void>;
room: IJitsiConferenceRoom;
sendApplicationLog: Function;
sendCommand: Function;
Expand Down
1 change: 1 addition & 0 deletions react/features/base/config/configType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export interface IConfig {
enableEncodedTransformSupport?: boolean;
enableForcedReload?: boolean;
enableIceRestart?: boolean;
enableIceRestartOnNetworkChange?: boolean;
enableInsecureRoomNameWarning?: boolean;
/**
* @deprecated Use `lobby.enableChat` instead.
Expand Down
1 change: 1 addition & 0 deletions react/features/base/lib-jitsi-meet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const JitsiConnectionEvents = JitsiMeetJS.events.connection;
export const JitsiConnectionQualityEvents = JitsiMeetJS.events.connectionQuality;
export const JitsiDetectionEvents = JitsiMeetJS.events.detection;
export const JitsiE2ePingEvents = JitsiMeetJS.events.e2eping;
export const JitsiIceRestartReason = JitsiMeetJS.constants.iceRestartReason;
export const JitsiMediaDevicesEvents = JitsiMeetJS.events.mediaDevices;
export const JitsiTrackStreamingStatus = JitsiMeetJS.constants.trackStreamingStatus;
export const JitsiRecordingConstants = JitsiMeetJS.constants.recording;
Expand Down
3 changes: 2 additions & 1 deletion react/features/base/net-info/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { assign } from '../redux/functions';

import { SET_NETWORK_INFO, _STORE_NETWORK_INFO_CLEANUP } from './actionTypes';
import { STORE_NAME } from './constants';
import { NetworkInfo } from './types';

const DEFAULT_STATE = {
isOnline: true
Expand All @@ -13,7 +14,7 @@ const DEFAULT_STATE = {
export interface INetInfoState {
_cleanup?: Function;
cellularGeneration?: NetInfoCellularGeneration;
details?: Object;
details?: NetworkInfo['details'];
isOnline?: boolean;
networkType?: NetInfoStateType;
}
Expand Down
6 changes: 6 additions & 0 deletions react/features/base/net-info/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export type NetworkInfo = {
*/
cellularGeneration?: NetInfoCellularGeneration | null;

/**
* The local IP address on the current interface. Reported for wifi and ethernet only (cellular does not
* expose it), and used to tell one wifi network apart from another.
*/
ipAddress?: string | null;

/**
* Indicates whether or not the connection is expensive.
*/
Expand Down
Loading