diff --git a/config.js b/config.js index c9d4f815c361..e89277f170ba 100644 --- a/config.js +++ b/config.js @@ -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) diff --git a/react/features/base/conference/middleware.native.ts b/react/features/base/conference/middleware.native.ts index b8db7dc1c077..6252fa322788 100644 --- a/react/features/base/conference/middleware.native.ts +++ b/react/features/base/conference/middleware.native.ts @@ -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(); diff --git a/react/features/base/conference/networkChangeIceRestart.native.ts b/react/features/base/conference/networkChangeIceRestart.native.ts new file mode 100644 index 000000000000..4c1bc81f08b4 --- /dev/null +++ b/react/features/base/conference/networkChangeIceRestart.native.ts @@ -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 | 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); +} diff --git a/react/features/base/conference/reducer.ts b/react/features/base/conference/reducer.ts index 1a29b71c0338..40e459cf0bfb 100644 --- a/react/features/base/conference/reducer.ts +++ b/react/features/base/conference/reducer.ts @@ -139,6 +139,7 @@ export interface IJitsiConference { isE2EEEnabled: Function; isE2EESupported: Function; isEndConferenceSupported: Function; + isIceRestartSupported: Function; isLobbySupported: Function; isP2PActive: Function; isSIPCallingSupported: Function; @@ -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; room: IJitsiConferenceRoom; sendApplicationLog: Function; sendCommand: Function; diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index d597266b1b97..5ee70da14569 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -422,6 +422,7 @@ export interface IConfig { enableEncodedTransformSupport?: boolean; enableForcedReload?: boolean; enableIceRestart?: boolean; + enableIceRestartOnNetworkChange?: boolean; enableInsecureRoomNameWarning?: boolean; /** * @deprecated Use `lobby.enableChat` instead. diff --git a/react/features/base/lib-jitsi-meet/index.ts b/react/features/base/lib-jitsi-meet/index.ts index 1e5ea1934194..2102b5917730 100644 --- a/react/features/base/lib-jitsi-meet/index.ts +++ b/react/features/base/lib-jitsi-meet/index.ts @@ -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; diff --git a/react/features/base/net-info/reducer.ts b/react/features/base/net-info/reducer.ts index 72b7c409f1cc..44c8c93b6732 100644 --- a/react/features/base/net-info/reducer.ts +++ b/react/features/base/net-info/reducer.ts @@ -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 @@ -13,7 +14,7 @@ const DEFAULT_STATE = { export interface INetInfoState { _cleanup?: Function; cellularGeneration?: NetInfoCellularGeneration; - details?: Object; + details?: NetworkInfo['details']; isOnline?: boolean; networkType?: NetInfoStateType; } diff --git a/react/features/base/net-info/types.ts b/react/features/base/net-info/types.ts index 9f80a8e5ea38..ca7eb71e1b14 100644 --- a/react/features/base/net-info/types.ts +++ b/react/features/base/net-info/types.ts @@ -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. */