Skip to content

Commit 348879b

Browse files
Avoid permissions API on Android WebView (#5888)
1 parent a1bf647 commit 348879b

8 files changed

Lines changed: 115 additions & 53 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "patch",
3+
"area": "fix",
4+
"workstream": "",
5+
"comment": "Improve device permission checks on Android WebViews",
6+
"packageName": "@azure/communication-react",
7+
"email": "2684369+JamesBurnside@users.noreply.github.com",
8+
"dependentChangeType": "patch"
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "patch",
3+
"area": "fix",
4+
"workstream": "",
5+
"comment": "Improve device permission checks on Android WebViews",
6+
"packageName": "@azure/communication-react",
7+
"email": "2684369+JamesBurnside@users.noreply.github.com",
8+
"dependentChangeType": "patch"
9+
}

packages/calling-stateful-client/src/DeviceManagerDeclarative.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,11 @@ class ProxyDeviceManager implements ProxyHandler<DeviceManager> {
179179
navigator.permissions.query({ name: 'microphone' as PermissionName })
180180
]);
181181

182-
hasCameraPermission = cameraPermissions.state === 'granted';
183-
hasMicPermission = micPermissions.state === 'granted';
182+
// Use logical OR to combine the SDK state with the Permissions API state.
183+
// This way we can get a more accurate picture of the device permission state.
184+
// If the SDK state is 'granted', we don't need to check the Permissions API state.
185+
hasCameraPermission ||= cameraPermissions.state === 'granted';
186+
hasMicPermission ||= micPermissions.state === 'granted';
184187
} catch (e) {
185188
console.info('Permissions API is not supported by browser', e);
186189
}

packages/react-composites/src/composites/CallComposite/pages/ConfigurationPage.tsx

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
import React, { useCallback, useMemo, useRef } from 'react';
5-
import { useState } from 'react';
4+
import React, { useCallback, useMemo, useRef, useState } from 'react';
5+
/* @conditional-compile-remove(call-readiness) */
6+
import { useEffect } from 'react';
67
import { useAdaptedSelector } from '../hooks/useAdaptedSelector';
78
import { useHandlers } from '../hooks/useHandlers';
89
import { LocalDeviceSettings } from '../components/LocalDeviceSettings';
@@ -136,13 +137,14 @@ export const ConfigurationPage = (props: ConfigurationPageProps): JSX.Element =>
136137
const options = useAdaptedSelector(getCallingSelector(DevicesButton));
137138
const localDeviceSettingsHandlers = useHandlers(LocalDeviceSettings);
138139
const { video: cameraPermissionGranted, audio: microphonePermissionGranted } = useSelector(devicePermissionSelector);
140+
const environmentInfo = useSelector(getEnvironmentInfo);
139141
/* @conditional-compile-remove(call-readiness) */
140142
// use permission API to get video and audio permission state
141143
const [videoState, setVideoState] = useState<PermissionState | 'unsupported' | undefined>(undefined);
142144
/* @conditional-compile-remove(call-readiness) */
143145
const [audioState, setAudioState] = useState<PermissionState | 'unsupported' | undefined>(undefined);
144146
/* @conditional-compile-remove(call-readiness) */
145-
getDevicePermissionState(setVideoState, setAudioState);
147+
getDevicePermissionState(environmentInfo, setVideoState, setAudioState, videoState, audioState);
146148

147149
const configContainerRef = useRef<HTMLDivElement>(null);
148150

@@ -155,7 +157,6 @@ export const ConfigurationPage = (props: ConfigurationPageProps): JSX.Element =>
155157
const stackConfig = !!configWidth && configWidth < 450;
156158
const errorBarProps = usePropsFor(ErrorBar);
157159
const microphones = useSelector(getMicrophones);
158-
const environmentInfo = useSelector(getEnvironmentInfo);
159160

160161
let disableStartCallButton =
161162
(!microphonePermissionGranted || microphones?.length === 0) &&
@@ -253,29 +254,33 @@ export const ConfigurationPage = (props: ConfigurationPageProps): JSX.Element =>
253254
const permissionsState: {
254255
camera: PermissionState;
255256
microphone: PermissionState;
256-
} = {
257-
// fall back to using cameraPermissionGranted and microphonePermissionGranted if permission API is not supported
258-
camera:
259-
videoState && videoState !== 'unsupported'
260-
? cameraPermissionGranted !== false
261-
? videoState
262-
: 'denied'
263-
: cameraPermissionGranted !== false
264-
? cameraPermissionGranted
265-
? 'granted'
266-
: 'prompt'
267-
: 'denied',
268-
microphone:
269-
audioState && audioState !== 'unsupported'
270-
? microphonePermissionGranted !== false
271-
? audioState
272-
: 'denied'
273-
: microphonePermissionGranted !== false
274-
? microphonePermissionGranted
275-
? 'granted'
276-
: 'prompt'
277-
: 'denied'
278-
};
257+
} = useMemo(
258+
() => ({
259+
// fall back to using cameraPermissionGranted and microphonePermissionGranted if permission API is not supported
260+
camera:
261+
videoState && videoState !== 'unsupported'
262+
? cameraPermissionGranted !== false
263+
? videoState
264+
: 'denied'
265+
: cameraPermissionGranted !== false
266+
? cameraPermissionGranted
267+
? 'granted'
268+
: 'prompt'
269+
: 'denied',
270+
microphone:
271+
audioState && audioState !== 'unsupported'
272+
? microphonePermissionGranted !== false
273+
? audioState
274+
: 'denied'
275+
: microphonePermissionGranted !== false
276+
? microphonePermissionGranted
277+
? 'granted'
278+
: 'prompt'
279+
: 'denied'
280+
}),
281+
[videoState, audioState, cameraPermissionGranted, microphonePermissionGranted]
282+
);
283+
279284
/* @conditional-compile-remove(call-readiness) */
280285
const networkErrors = errorBarProps.activeErrorMessages.filter((message) => message.type === 'callNetworkQualityLow');
281286

@@ -287,9 +292,11 @@ export const ConfigurationPage = (props: ConfigurationPageProps): JSX.Element =>
287292
/* @conditional-compile-remove(call-readiness) */
288293
const [minimumFallbackTimerElapsed, setMinimumFallbackTimerElapsed] = useState(false);
289294
/* @conditional-compile-remove(call-readiness) */
290-
setTimeout(() => {
291-
setMinimumFallbackTimerElapsed(true);
292-
}, 2000);
295+
useEffect(() => {
296+
setTimeout(() => {
297+
setMinimumFallbackTimerElapsed(true);
298+
}, 2000);
299+
}, []);
293300
/* @conditional-compile-remove(call-readiness) */
294301
const forceShowingCheckPermissions = !minimumFallbackTimerElapsed;
295302

packages/react-composites/src/composites/CallComposite/utils/Utils.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -427,15 +427,32 @@ export const isDisabled = (option: boolean | { disabled: boolean } | undefined):
427427
return option.disabled;
428428
};
429429

430+
/* @conditional-compile-remove(call-readiness) */
431+
const isAndroidWebView = (environmentInfo?: EnvironmentInfo): boolean => {
432+
const ua = navigator.userAgent || '';
433+
return (
434+
environmentInfo?.environment?.browser?.toLowerCase() === 'chrome webview' ||
435+
(/Android/.test(ua) && /Version\/[\d.]+/.test(ua) && /wv/.test(ua))
436+
);
437+
};
438+
430439
/* @conditional-compile-remove(call-readiness) */
431440
/**
432441
* @returns Permissions state for the camera.
433442
*/
434-
const queryCameraPermissionFromPermissionsAPI = async (): Promise<PermissionState | 'unsupported'> => {
443+
const queryCameraPermissionFromPermissionsAPI = async (
444+
environmentInfo?: EnvironmentInfo
445+
): Promise<PermissionState | 'unsupported'> => {
446+
// Android WebView does not support permissions API, nor does it throw an error when trying to access it,
447+
// in actuality the API always returns 'prompt' which is not correct.
448+
if (isAndroidWebView(environmentInfo)) {
449+
return 'unsupported';
450+
}
451+
435452
try {
436453
return (await navigator.permissions.query({ name: 'camera' as PermissionName })).state;
454+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
437455
} catch (e) {
438-
console.info('permissions API is not supported by browser', e);
439456
return 'unsupported';
440457
}
441458
};
@@ -444,11 +461,19 @@ const queryCameraPermissionFromPermissionsAPI = async (): Promise<PermissionStat
444461
/**
445462
* @returns Permissions state for the microphone.
446463
*/
447-
const queryMicrophonePermissionFromPermissionsAPI = async (): Promise<PermissionState | 'unsupported'> => {
464+
const queryMicrophonePermissionFromPermissionsAPI = async (
465+
environmentInfo?: EnvironmentInfo
466+
): Promise<PermissionState | 'unsupported'> => {
467+
// Android WebView does not support permissions API, nor does it throw an error when trying to access it,
468+
// in actuality the API always returns 'prompt' which is not correct.
469+
if (isAndroidWebView(environmentInfo)) {
470+
return 'unsupported';
471+
}
472+
448473
try {
449474
return (await navigator.permissions.query({ name: 'microphone' as PermissionName })).state;
475+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
450476
} catch (e) {
451-
console.info('permissions API is not supported by browser', e);
452477
return 'unsupported';
453478
}
454479
};
@@ -462,15 +487,24 @@ const queryMicrophonePermissionFromPermissionsAPI = async (): Promise<Permission
462487
* @private
463488
*/
464489
export const getDevicePermissionState = async (
490+
environmentInfo: undefined | EnvironmentInfo,
465491
setVideoState: (state: PermissionState | 'unsupported') => void,
466-
setAudioState: (state: PermissionState | 'unsupported') => void
492+
setAudioState: (state: PermissionState | 'unsupported') => void,
493+
previousVideoState: PermissionState | 'unsupported' | undefined,
494+
previousAudioState: PermissionState | 'unsupported' | undefined
467495
): Promise<void> => {
468496
const [cameraResult, microphoneResult] = await Promise.all([
469-
queryCameraPermissionFromPermissionsAPI(),
470-
queryMicrophonePermissionFromPermissionsAPI()
497+
queryCameraPermissionFromPermissionsAPI(environmentInfo),
498+
queryMicrophonePermissionFromPermissionsAPI(environmentInfo)
471499
]);
472-
setVideoState(cameraResult);
473-
setAudioState(microphoneResult);
500+
501+
if (cameraResult !== previousVideoState) {
502+
setVideoState(cameraResult);
503+
}
504+
505+
if (microphoneResult !== previousAudioState) {
506+
setAudioState(microphoneResult);
507+
}
474508
};
475509
/* @conditional-compile-remove(unsupported-browser) */
476510
const isUnsupportedEnvironment = (

samples/CallWithChat/src/app/utils/localStorage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,22 @@ export enum LocalStorageKeys {
1212
* Get display name from local storage.
1313
*/
1414
export const getDisplayNameFromLocalStorage = (): string | null =>
15-
window.localStorage.getItem(LocalStorageKeys.DisplayName);
15+
window.localStorage?.getItem(LocalStorageKeys.DisplayName);
1616

1717
/**
1818
* Save display name into local storage.
1919
*/
2020
export const saveDisplayNameToLocalStorage = (displayName: string): void =>
21-
window.localStorage.setItem(LocalStorageKeys.DisplayName, displayName);
21+
window.localStorage?.setItem(LocalStorageKeys.DisplayName, displayName);
2222

2323
/**
2424
* Get theme from local storage.
2525
*/
2626
export const getThemeFromLocalStorage = (scopeId: string): string | null =>
27-
window.localStorage.getItem(LocalStorageKeys.Theme + '_' + scopeId);
27+
window.localStorage?.getItem(LocalStorageKeys.Theme + '_' + scopeId);
2828

2929
/**
3030
* Save theme into local storage.
3131
*/
3232
export const saveThemeToLocalStorage = (theme: string, scopeId: string): void =>
33-
window.localStorage.setItem(LocalStorageKeys.Theme + '_' + scopeId, theme);
33+
window.localStorage?.setItem(LocalStorageKeys.Theme + '_' + scopeId, theme);

samples/Calling/src/app/utils/localStorage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,22 @@ export enum LocalStorageKeys {
1212
* Get display name from local storage.
1313
*/
1414
export const getDisplayNameFromLocalStorage = (): string | null =>
15-
window.localStorage.getItem(LocalStorageKeys.DisplayName);
15+
window.localStorage?.getItem(LocalStorageKeys.DisplayName);
1616

1717
/**
1818
* Save display name into local storage.
1919
*/
2020
export const saveDisplayNameToLocalStorage = (displayName: string): void =>
21-
window.localStorage.setItem(LocalStorageKeys.DisplayName, displayName);
21+
window.localStorage?.setItem(LocalStorageKeys.DisplayName, displayName);
2222

2323
/**
2424
* Get theme from local storage.
2525
*/
2626
export const getThemeFromLocalStorage = (scopeId: string): string | null =>
27-
window.localStorage.getItem(LocalStorageKeys.Theme + '_' + scopeId);
27+
window.localStorage?.getItem(LocalStorageKeys.Theme + '_' + scopeId);
2828

2929
/**
3030
* Save theme into local storage.
3131
*/
3232
export const saveThemeToLocalStorage = (theme: string, scopeId: string): void =>
33-
window.localStorage.setItem(LocalStorageKeys.Theme + '_' + scopeId, theme);
33+
window.localStorage?.setItem(LocalStorageKeys.Theme + '_' + scopeId, theme);

samples/Chat/src/app/utils/localStorage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,22 @@ export enum LocalStorageKeys {
1212
* Get display name from local storage.
1313
*/
1414
export const getDisplayNameFromLocalStorage = (): string | null =>
15-
window.localStorage.getItem(LocalStorageKeys.DisplayName);
15+
window.localStorage?.getItem(LocalStorageKeys.DisplayName);
1616

1717
/**
1818
* Save display name into local storage.
1919
*/
2020
export const saveDisplayNameToLocalStorage = (displayName: string): void =>
21-
window.localStorage.setItem(LocalStorageKeys.DisplayName, displayName);
21+
window.localStorage?.setItem(LocalStorageKeys.DisplayName, displayName);
2222

2323
/**
2424
* Get theme from local storage.
2525
*/
2626
export const getThemeFromLocalStorage = (scopeId: string): string | null =>
27-
window.localStorage.getItem(LocalStorageKeys.Theme + '_' + scopeId);
27+
window.localStorage?.getItem(LocalStorageKeys.Theme + '_' + scopeId);
2828

2929
/**
3030
* Save theme into local storage.
3131
*/
3232
export const saveThemeToLocalStorage = (theme: string, scopeId: string): void =>
33-
window.localStorage.setItem(LocalStorageKeys.Theme + '_' + scopeId, theme);
33+
window.localStorage?.setItem(LocalStorageKeys.Theme + '_' + scopeId, theme);

0 commit comments

Comments
 (0)