bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/audio/service.js
prlanzarin 1e53140a00 fix(audio): prevent permission check loop in Safari
Safari may enter a microphone permission check loop due to buggy behavior
in the Permissions API. When permission isn't permanently denied, gUM
requests fail with a NotAllowedError for a few seconds. During this time,
the permission state remains 'prompt' instead of transitioning to 'denied'
and back to 'prompt' after the timeout.

This leads to an issue where, on retrying while in 'prompt' + blocked,
the client loops through gUM checks via: 1) checking permission in the API,
2) receiving 'prompt', so trying gUM, 3) gUM fails, 4) returning to the
modal and checking permission again because the API still says 'prompt'.

Additionally, the `isUsingAudio` flag incorrectly counts the local echo
test/audio settings modal as "using audio," which toggles the flag on/off,
triggering the useEffect that causes the loop more frequently.

To fix this, remove the unnecessary AudioModal permission check that
causes the loop. Also, exclude "isEchoTest" from the `isUsingAudio` flag.
2024-08-20 19:19:08 +00:00

207 lines
6.7 KiB
JavaScript
Executable File

import Auth from '/imports/ui/services/auth';
import AudioManager from '/imports/ui/services/audio-manager';
import logger from '/imports/startup/client/logger';
import Storage from '../../services/storage/session';
import { useReactiveVar } from '@apollo/client';
import {
getAudioConstraints,
doGUM,
} from '/imports/api/audio/client/bridge/service';
import {
toggleMuteMicrophone,
toggleMuteMicrophoneSystem,
} from '/imports/ui/components/audio/audio-graphql/audio-controls/input-stream-live-selector/service';
const MUTED_KEY = 'muted';
const recoverMicState = (toggleVoice) => {
const muted = Storage.getItem(MUTED_KEY);
if ((muted === undefined) || (muted === null) || AudioManager.inputDeviceId === 'listen-only') {
return;
}
logger.debug({
logCode: 'audio_recover_mic_state',
}, `Audio recover previous mic state: muted = ${muted}`);
toggleVoice(Auth.userID, muted);
};
const audioEventHandler = (toggleVoice) => (event) => {
if (!event) {
return;
}
switch (event.name) {
case 'started':
if (!event.isListenOnly) recoverMicState(toggleVoice);
break;
default:
break;
}
};
const init = (messages, intl, toggleVoice, speechLocale, voiceConf, username) => {
AudioManager.setAudioMessages(messages, intl);
if (AudioManager.initialized) return Promise.resolve(false);
const meetingId = Auth.meetingID;
const userId = Auth.userID;
const { sessionToken } = Auth;
const voiceBridge = voiceConf;
// FIX ME
const microphoneLockEnforced = false;
const userData = {
meetingId,
userId,
sessionToken,
username,
voiceBridge,
microphoneLockEnforced,
speechLocale,
};
return AudioManager.init(userData, audioEventHandler(toggleVoice));
};
const useIsUsingAudio = () => {
const isConnected = useReactiveVar(AudioManager._isConnected.value);
const isConnecting = useReactiveVar(AudioManager._isConnecting.value);
const isHangingUp = useReactiveVar(AudioManager._isHangingUp.value);
return Boolean(isConnected || isConnecting || isHangingUp);
};
/**
* Check if the user has granted permission to use the microphone.
*
* @param {Object} options - Options object.
* @param {string} options.permissionStatus - The current permission status.
* @param {boolean} options.gumOnPrompt - Whether to check microphone permission by attempting to
* get a media stream.
* @returns {Promise<boolean|null>} - A promise that resolves to a boolean indicating whether the
* user has granted permission to use the microphone. If the permission status is unknown, the
* promise resolves to null.
*/
const hasMicrophonePermission = async ({
permissionStatus = null,
gumOnPrompt = false,
}) => {
const checkWithGUM = () => {
if (!gumOnPrompt) return Promise.resolve(null);
return doGUM({ audio: getAudioConstraints() })
.then((stream) => {
// Close the stream and remove all tracks - this is just a permission check
stream.getTracks().forEach((track) => {
track.stop();
stream.removeTrack(track);
});
return true;
})
.catch((error) => {
if (error.name === 'NotAllowedError') return false;
// Give it the benefit of the doubt. It might be a device mismatch
// or something else that's not a permissions issue, so let's try
// to proceed. Rollbacks that happen downstream might fix the issue,
// otherwise we'll land on the Help screen anyways
return null;
});
};
try {
let status = permissionStatus;
// If the browser doesn't support the Permissions API, we can't check
// microphone permissions - return null (unknown)
if (navigator?.permissions?.query == null) return null;
if (!status) {
({ state: status } = await navigator.permissions.query({ name: 'microphone' }));
}
switch (status) {
case 'denied':
return false;
case 'granted':
return true;
case null:
case 'prompt':
return checkWithGUM();
default:
return null;
}
} catch (error) {
logger.warn({
logCode: 'audio_check_microphone_permission_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error checking microphone permission: ${error.message}`);
return checkWithGUM();
}
};
export default {
init,
exitAudio: () => AudioManager.exitAudio(),
forceExitAudio: () => AudioManager.forceExitAudio(),
transferCall: () => AudioManager.transferCall(),
joinListenOnly: () => AudioManager.joinListenOnly(),
joinMicrophone: (options) => AudioManager.joinMicrophone(options),
joinEchoTest: () => AudioManager.joinEchoTest(),
changeInputDevice: (inputDeviceId) => AudioManager.changeInputDevice(inputDeviceId),
changeInputStream: (newInputStream) => { AudioManager.inputStream = newInputStream; },
liveChangeInputDevice: (inputDeviceId) => AudioManager.liveChangeInputDevice(inputDeviceId),
changeOutputDevice: (
outputDeviceId,
isLive,
) => AudioManager.changeOutputDevice(outputDeviceId, isLive),
toggleMuteMicrophone,
toggleMuteMicrophoneSystem,
isConnectedToBreakout: () => {
const transferStatus = AudioManager.getBreakoutAudioTransferStatus();
if (transferStatus.status
=== AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.CONNECTED) return true;
return false;
},
isConnected: () => {
const transferStatus = AudioManager.getBreakoutAudioTransferStatus();
if (!!transferStatus.breakoutMeetingId
&& transferStatus.breakoutMeetingId !== Auth.meetingID) return false;
return AudioManager.isConnected;
},
isUsingAudio: () => AudioManager.isUsingAudio(),
isConnecting: () => AudioManager.isConnecting,
isListenOnly: () => AudioManager.isListenOnly,
inputDeviceId: () => AudioManager.inputDeviceId,
outputDeviceId: () => AudioManager.outputDeviceId,
isEchoTest: () => AudioManager.isEchoTest,
isMuted: () => AudioManager.isMuted,
autoplayBlocked: () => AudioManager.autoplayBlocked,
handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(),
playAlertSound: (url) => AudioManager.playAlertSound(url),
updateAudioConstraints: (constraints) => AudioManager.updateAudioConstraints(constraints),
recoverMicState,
isReconnecting: () => AudioManager.isReconnecting,
setBreakoutAudioTransferStatus: (status) => AudioManager
.setBreakoutAudioTransferStatus(status),
getBreakoutAudioTransferStatus: () => AudioManager
.getBreakoutAudioTransferStatus(),
getStats: () => AudioManager.getStats(),
getAudioConstraints,
doGUM,
supportsTransparentListenOnly: () => AudioManager.supportsTransparentListenOnly(),
hasMicrophonePermission,
notify: (message, error, icon) => { AudioManager.notify(message, error, icon); },
useIsUsingAudio,
};