Skip to content

Commit bfcebd0

Browse files
Avoid permissions API on Android WebView (#5888)
1 parent 80f6424 commit bfcebd0

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
@@ -430,15 +430,32 @@ export const isDisabled = (option: boolean | { disabled: boolean } | undefined):
430430
return option.disabled;
431431
};
432432

433+
/* @conditional-compile-remove(call-readiness) */
434+
const isAndroidWebView = (environmentInfo?: EnvironmentInfo): boolean => {
435+
const ua = navigator.userAgent || '';
436+
return (
437+
environmentInfo?.environment?.browser?.toLowerCase() === 'chrome webview' ||
438+
(/Android/.test(ua) && /Version\/[\d.]+/.test(ua) && /wv/.test(ua))
439+
);
440+
};
441+
433442
/* @conditional-compile-remove(call-readiness) */
434443
/**
435444
* @returns Permissions state for the camera.
436445
*/
437-
const queryCameraPermissionFromPermissionsAPI = async (): Promise<PermissionState | 'unsupported'> => {
446+
const queryCameraPermissionFromPermissionsAPI = async (
447+
environmentInfo?: EnvironmentInfo
448+
): Promise<PermissionState | 'unsupported'> => {
449+
// Android WebView does not support permissions API, nor does it throw an error when trying to access it,
450+
// in actuality the API always returns 'prompt' which is not correct.
451+
if (isAndroidWebView(environmentInfo)) {
452+
return 'unsupported';
453+
}
454+
438455
try {
439456
return (await navigator.permissions.query({ name: 'camera' as PermissionName })).state;
457+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
440458
} catch (e) {
441-
console.info('permissions API is not supported by browser', e);
442459
return 'unsupported';
443460
}
444461
};
@@ -447,11 +464,19 @@ const queryCameraPermissionFromPermissionsAPI = async (): Promise<PermissionStat
447464
/**
448465
* @returns Permissions state for the microphone.
449466
*/
450-
const queryMicrophonePermissionFromPermissionsAPI = async (): Promise<PermissionState | 'unsupported'> => {
467+
const queryMicrophonePermissionFromPermissionsAPI = async (
468+
environmentInfo?: EnvironmentInfo
469+
): Promise<PermissionState | 'unsupported'> => {
470+
// Android WebView does not support permissions API, nor does it throw an error when trying to access it,
471+
// in actuality the API always returns 'prompt' which is not correct.
472+
if (isAndroidWebView(environmentInfo)) {
473+
return 'unsupported';
474+
}
475+
451476
try {
452477
return (await navigator.permissions.query({ name: 'microphone' as PermissionName })).state;
478+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
453479
} catch (e) {
454-
console.info('permissions API is not supported by browser', e);
455480
return 'unsupported';
456481
}
457482
};
@@ -465,15 +490,24 @@ const queryMicrophonePermissionFromPermissionsAPI = async (): Promise<Permission
465490
* @private
466491
*/
467492
export const getDevicePermissionState = async (
493+
environmentInfo: undefined | EnvironmentInfo,
468494
setVideoState: (state: PermissionState | 'unsupported') => void,
469-
setAudioState: (state: PermissionState | 'unsupported') => void
495+
setAudioState: (state: PermissionState | 'unsupported') => void,
496+
previousVideoState: PermissionState | 'unsupported' | undefined,
497+
previousAudioState: PermissionState | 'unsupported' | undefined
470498
): Promise<void> => {
471499
const [cameraResult, microphoneResult] = await Promise.all([
472-
queryCameraPermissionFromPermissionsAPI(),
473-
queryMicrophonePermissionFromPermissionsAPI()
500+
queryCameraPermissionFromPermissionsAPI(environmentInfo),
501+
queryMicrophonePermissionFromPermissionsAPI(environmentInfo)
474502
]);
475-
setVideoState(cameraResult);
476-
setAudioState(microphoneResult);
503+
504+
if (cameraResult !== previousVideoState) {
505+
setVideoState(cameraResult);
506+
}
507+
508+
if (microphoneResult !== previousAudioState) {
509+
setAudioState(microphoneResult);
510+
}
477511
};
478512
/* @conditional-compile-remove(unsupported-browser) */
479513
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)