From 902263d7c92a82073db3cf66f4cba6685c5b320b Mon Sep 17 00:00:00 2001 From: Enrico Schwendig Date: Thu, 6 Jul 2023 16:40:14 +0200 Subject: [PATCH] force to allow calls without video and audio in embedded mode (#11131) * force to allow calls without video and audio in embedded mode * Check device access permission and introduce a only screen share call mode * Fix strict typ check issue * Fix i18n check issue * Add unit tests for device selection * Fix mocked media device query --- src/components/views/voip/CallView.tsx | 25 +++++++++++-- src/i18n/strings/en_EN.json | 3 +- src/models/Call.ts | 1 + src/settings/Settings.tsx | 9 +++++ test/components/views/voip/CallView-test.tsx | 37 ++++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 6472bf12e13..3f1f3b7d45e 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -32,7 +32,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import MediaDeviceHandler from "../../../MediaDeviceHandler"; +import MediaDeviceHandler, { IMediaDevices } from "../../../MediaDeviceHandler"; import { CallStore } from "../../../stores/CallStore"; import IconizedContextMenu, { IconizedContextMenuOption, @@ -149,14 +149,32 @@ export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, con setVideoMuted(!videoMuted); }, [videoMuted, setVideoMuted]); + // In case we can not fetch media devices we should mute the devices + const handleMediaDeviceFailing = (message: string): void => { + MediaDeviceHandler.startWithAudioMuted = true; + MediaDeviceHandler.startWithVideoMuted = true; + logger.warn(message); + }; + const [videoStream, audioInputs, videoInputs] = useAsyncMemo( async (): Promise<[MediaStream | null, MediaDeviceInfo[], MediaDeviceInfo[]]> => { - let devices = await MediaDeviceHandler.getDevices(); + let devices: IMediaDevices | undefined; + try { + devices = await MediaDeviceHandler.getDevices(); + if (devices === undefined) { + handleMediaDeviceFailing("Could not access devices!"); + return [null, [], []]; + } + } catch (error) { + handleMediaDeviceFailing(`Unable to get Media Devices: ${error}`); + return [null, [], []]; + } // We get the preview stream before requesting devices: this is because // we need (in some browsers) an active media stream in order to get // non-blank labels for the devices. let stream: MediaStream | null = null; + try { if (devices!.audioinput.length > 0) { // Holding just an audio stream will be enough to get us all device labels, so @@ -170,7 +188,8 @@ export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, con stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } }); } } catch (e) { - logger.error(`Failed to get stream for device ${videoInputId}`, e); + logger.warn(`Failed to get stream for device ${videoInputId}`, e); + handleMediaDeviceFailing(`Have access to Device list but unable to read from Media Devices`); } // Refresh the devices now that we hold a stream diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5c51a0f7fce..0434251d248 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -986,6 +986,8 @@ "Under active development, cannot be disabled.": "Under active development, cannot be disabled.", "Element Call video rooms": "Element Call video rooms", "New group call experience": "New group call experience", + "Under active development.": "Under active development.", + "Allow screen share only mode": "Allow screen share only mode", "Live Location Sharing": "Live Location Sharing", "Temporary implementation. Locations persist in room history.": "Temporary implementation. Locations persist in room history.", "Dynamic room predecessors": "Dynamic room predecessors", @@ -993,7 +995,6 @@ "Force 15s voice broadcast chunk length": "Force 15s voice broadcast chunk length", "Enable new native OIDC flows (Under active development)": "Enable new native OIDC flows (Under active development)", "Rust cryptography implementation": "Rust cryptography implementation", - "Under active development.": "Under active development.", "Font size": "Font size", "Use custom size": "Use custom size", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", diff --git a/src/models/Call.ts b/src/models/Call.ts index 2b5b8972e6a..f7b7fbb4950 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -660,6 +660,7 @@ export class ElementCall extends Call { }); if (SettingsStore.getValue("fallbackICEServerAllowed")) params.append("allowIceFallback", ""); + if (SettingsStore.getValue("feature_allow_screen_share_only_mode")) params.append("allowVoipWithNoMedia", ""); // Set custom fonts if (SettingsStore.getValue("useSystemFont")) { diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 5f1cbe8165c..f8f0840551f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -433,6 +433,15 @@ export const SETTINGS: { [setting: string]: ISetting } = { controller: new ReloadOnChangeController(), default: false, }, + "feature_allow_screen_share_only_mode": { + isFeature: true, + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + description: _td("Under active development."), + labsGroup: LabGroup.VoiceAndVideo, + displayName: _td("Allow screen share only mode"), + controller: new ReloadOnChangeController(), + default: false, + }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index 9c276babed4..ec2bca712c6 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -39,6 +39,7 @@ import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessa import { CallStore } from "../../../../src/stores/CallStore"; import { Call, ConnectionState } from "../../../../src/models/Call"; import SdkConfig from "../../../../src/SdkConfig"; +import MediaDeviceHandler from "../../../../src/MediaDeviceHandler"; const CallView = wrapInMatrixClientContext(_CallView); @@ -247,6 +248,26 @@ describe("CallLobby", () => { expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); }); + it("hide when no access to device list", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockRejectedValue("permission denied"); + await renderView(); + expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); + expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); + expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); + expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); + }); + + it("hide when unknown error with device list", async () => { + const originalGetDevices = MediaDeviceHandler.getDevices; + MediaDeviceHandler.getDevices = () => Promise.reject("unknown error"); + await renderView(); + expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); + expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); + expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null); + expect(screen.queryByRole("button", { name: /camera/ })).toBe(null); + MediaDeviceHandler.getDevices = originalGetDevices; + }); + it("show without dropdown when only one device is available", async () => { mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]); @@ -286,5 +307,21 @@ describe("CallLobby", () => { expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId); }); + + it("set media muted if no access to audio device", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]); + mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected"); + await renderView(); + expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); + expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); + }); + + it("set media muted if no access to video device", async () => { + mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]); + mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected"); + await renderView(); + expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy(); + expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy(); + }); }); });