Skip to content

Commit

Permalink
feat(device-api): Browser Permissions API (#1184)
Browse files Browse the repository at this point in the history
Support for querying browser permissions for camera and microphone.

### Breaking changes (React-SDK):
- `useHasBrowserPermissions` is not available in the SDK anymore. Please use the appropriate helper hooks:
```typescript
const { useMicrophoneState, useCameraState } = useCallStateHooks();

const { hasBrowserPermission: hasMicrophonePermission } = useMicrophoneState();
const { hasBrowserPermission: hasCameraPermission } = useCameraState();
```
  • Loading branch information
oliverlaz authored Nov 13, 2023
1 parent e6e888b commit a0b3573
Show file tree
Hide file tree
Showing 14 changed files with 254 additions and 60 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ call.camera.state.selectedDevice; // currently selected camera
call.camera.state.selectedDevice$.subscribe(console.log); // Reactive value for selected device, you can subscribe to changes
```

### Camera permissions

In a browser, you can use the following API to check whether the user has granted permission to access the connected cameras:

```typescript
call.camera.state.hasBrowserPermission$.subscribe((value) => {
if (value) {
// User has granted permission
} else {
// User has denied or not yet granted permission
}
});
```

### Camera direction

On mobile devices it's useful if users can switch between the front and back cameras:
Expand Down Expand Up @@ -168,6 +182,20 @@ call.microphone.state.selectedDevice; // currently selected microphone
call.microphone.state.selectedDevice$.subscribe(console.log); // Reactive value for selected device, you can subscribe to changes
```

### Microphone permissions

In a browser, you can use the following API to check whether the user has granted permission to access the connected microphones:

```typescript
call.microphone.state.hasBrowserPermission$.subscribe((value) => {
if (value) {
// User has granted permission
} else {
// User has denied or not yet granted permission
}
});
```

### Play audio

#### In call
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/devices/CameraManagerState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BehaviorSubject, Observable, distinctUntilChanged } from 'rxjs';
import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs';
import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';
import { isReactNative } from '../helpers/platforms';

Expand All @@ -15,7 +15,12 @@ export class CameraManagerState extends InputMediaDeviceManagerState {
direction$: Observable<CameraDirection>;

constructor() {
super('stop-tracks');
super(
'stop-tracks',
// `camera` is not in the W3C standard yet,
// but it's supported by Chrome and Safari.
'camera' as PermissionName,
);
this.direction$ = this.directionSubject
.asObservable()
.pipe(distinctUntilChanged());
Expand Down
5 changes: 2 additions & 3 deletions packages/client/src/devices/InputMediaDeviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,10 @@ export abstract class InputMediaDeviceManager<
}

/**
* Select device
* Selects a device.
*
* Note: this method is not supported in React Native
*
* @param deviceId
* @param deviceId the device id to select.
*/
async select(deviceId: string | undefined) {
if (isReactNative()) {
Expand Down
45 changes: 44 additions & 1 deletion packages/client/src/devices/InputMediaDeviceManagerState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { BehaviorSubject, distinctUntilChanged } from 'rxjs';
import {
BehaviorSubject,
distinctUntilChanged,
Observable,
shareReplay,
} from 'rxjs';
import { isReactNative } from '../helpers/platforms';
import { RxUtils } from '../store';

export type InputDeviceStatus = 'enabled' | 'disabled' | undefined;
Expand Down Expand Up @@ -43,10 +49,47 @@ export abstract class InputMediaDeviceManagerState<C = MediaTrackConstraints> {
*/
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();

/**
* An observable that will emit `true` if browser/system permission
* is granted, `false` otherwise.
*/
hasBrowserPermission$ = new Observable<boolean>((subscriber) => {
const notifyGranted = () => subscriber.next(true);
if (isReactNative() || !this.permissionName) return notifyGranted();

let permissionState: PermissionStatus;
const notify = () => subscriber.next(permissionState.state === 'granted');
navigator.permissions
.query({ name: this.permissionName })
.then((permissionStatus) => {
permissionState = permissionStatus;
permissionState.addEventListener('change', notify);
notify();
})
.catch(() => {
// permission doesn't exist or can't be queried -> assume it's granted
// an example would be Firefox,
// where neither camera microphone permission can be queried
notifyGranted();
});

return () => {
permissionState?.removeEventListener('change', notify);
};
}).pipe(shareReplay(1));

/**
* Constructs new InputMediaDeviceManagerState instance.
*
* @param disableMode the disable mode to use.
* @param permissionName the permission name to use for querying.
* `undefined` means no permission is required.
*/
constructor(
public readonly disableMode:
| 'stop-tracks'
| 'disable-tracks' = 'stop-tracks',
private readonly permissionName: PermissionName | undefined = undefined,
) {}

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/client/src/devices/MicrophoneManagerState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BehaviorSubject, Observable, distinctUntilChanged } from 'rxjs';
import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs';
import { InputMediaDeviceManagerState } from './InputMediaDeviceManagerState';

export class MicrophoneManagerState extends InputMediaDeviceManagerState {
Expand All @@ -12,7 +12,12 @@ export class MicrophoneManagerState extends InputMediaDeviceManagerState {
speakingWhileMuted$: Observable<boolean>;

constructor() {
super('disable-tracks');
super(
'disable-tracks',
// `microphone` is not in the W3C standard yet,
// but it's supported by Chrome and Safari.
'microphone' as PermissionName,
);

this.speakingWhileMuted$ = this.speakingWhileMutedSubject
.asObservable()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ class TestInputMediaDeviceManager extends InputMediaDeviceManager<TestInputMedia
public getTracks = () => this.state.mediaStream?.getTracks() ?? [];

constructor(call: Call) {
super(call, new TestInputMediaDeviceManagerState(), TrackType.VIDEO);
super(
call,
new TestInputMediaDeviceManagerState(
'stop-tracks',
'camera' as PermissionName,
),
TrackType.VIDEO,
);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { InputMediaDeviceManagerState } from '../InputMediaDeviceManagerState';

class TestInputMediaDeviceManagerState extends InputMediaDeviceManagerState {
constructor() {
super('stop-tracks', 'camera' as PermissionName);
}

getDeviceIdFromStream = vi.fn();
}

describe('InputMediaDeviceManagerState', () => {
let state: InputMediaDeviceManagerState;

beforeEach(() => {
state = new TestInputMediaDeviceManagerState();
});

describe('hasBrowserPermission', () => {
it('should emit true when permission is granted', async () => {
const permissionStatus: Partial<PermissionStatus> = {
state: 'granted',
addEventListener: vi.fn(),
};
const query = vi.fn(() => Promise.resolve(permissionStatus));
globalThis.navigator ??= {} as Navigator;
// @ts-ignore - navigator is readonly, but we need to mock it
globalThis.navigator.permissions = { query };

const hasPermission = await new Promise((resolve) => {
state.hasBrowserPermission$.subscribe((v) => resolve(v));
});
expect(hasPermission).toBe(true);
expect(query).toHaveBeenCalledWith({ name: 'camera' });
expect(permissionStatus.addEventListener).toHaveBeenCalled();
});

it(' should emit false when permission is denied', async () => {
const permissionStatus: Partial<PermissionStatus> = {
state: 'denied',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
const query = vi.fn(() => Promise.resolve(permissionStatus));
globalThis.navigator ??= {} as Navigator;
// @ts-ignore - navigator is readonly, but we need to mock it
globalThis.navigator.permissions = { query };

const hasPermission = await new Promise((resolve) => {
state.hasBrowserPermission$.subscribe((v) => resolve(v));
});
expect(hasPermission).toBe(false);
expect(query).toHaveBeenCalledWith({ name: 'camera' });
expect(permissionStatus.addEventListener).toHaveBeenCalled();
});

it('should emit false when prompt is needed', async () => {
const permissionStatus: Partial<PermissionStatus> = {
state: 'prompt',
addEventListener: vi.fn(),
};
const query = vi.fn(() => Promise.resolve(permissionStatus));
globalThis.navigator ??= {} as Navigator;
// @ts-ignore - navigator is readonly, but we need to mock it
globalThis.navigator.permissions = { query };

const hasPermission = await new Promise((resolve) => {
state.hasBrowserPermission$.subscribe((v) => resolve(v));
});
expect(hasPermission).toBe(false);
expect(query).toHaveBeenCalledWith({ name: 'camera' });
expect(permissionStatus.addEventListener).toHaveBeenCalled();
});

it('should emit true when permissions cannot be queried', async () => {
const query = vi.fn(() => Promise.reject());
globalThis.navigator ??= {} as Navigator;
// @ts-ignore - navigator is readonly, but we need to mock it
globalThis.navigator.permissions = { query };

const hasPermission = await new Promise((resolve) => {
state.hasBrowserPermission$.subscribe((v) => resolve(v));
});
expect(hasPermission).toBe(true);
expect(query).toHaveBeenCalledWith({ name: 'camera' });
});
});
});
13 changes: 9 additions & 4 deletions packages/react-bindings/src/hooks/callStateHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,13 @@ export const useCameraState = () => {

const devices$ = useMemo(() => camera.listDevices(), [camera]);

const status = useObservableValue(camera.state.status$);
const direction = useObservableValue(camera.state.direction$);
const mediaStream = useObservableValue(camera.state.mediaStream$);
const selectedDevice = useObservableValue(camera.state.selectedDevice$);
const { state } = camera;
const status = useObservableValue(state.status$);
const direction = useObservableValue(state.direction$);
const mediaStream = useObservableValue(state.mediaStream$);
const selectedDevice = useObservableValue(state.selectedDevice$);
const devices = useObservableValue(devices$);
const hasBrowserPermission = useObservableValue(state.hasBrowserPermission$);
const isMute = status !== 'enabled';

return {
Expand All @@ -346,6 +348,7 @@ export const useCameraState = () => {
direction,
mediaStream,
devices,
hasBrowserPermission,
selectedDevice,
isMute,
};
Expand All @@ -367,6 +370,7 @@ export const useMicrophoneState = () => {
const mediaStream = useObservableValue(state.mediaStream$);
const selectedDevice = useObservableValue(state.selectedDevice$);
const devices = useObservableValue(devices$);
const hasBrowserPermission = useObservableValue(state.hasBrowserPermission$);
const isSpeakingWhileMuted = useObservableValue(state.speakingWhileMuted$);
const isMute = status !== 'enabled';

Expand All @@ -377,6 +381,7 @@ export const useMicrophoneState = () => {
mediaStream,
devices,
selectedDevice,
hasBrowserPermission,
isSpeakingWhileMuted,
isMute,
};
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-sdk/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ global.navigator = {
getUserMedia: jest.fn().mockResolvedValue(mockedMedia),
enumerateDevices: jest.fn().mockResolvedValue(mockedDevices),
},
product: 'ReactNative',
};
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ const preferredDevice = devices.find((d) => d.label === 'My Camera');
await camera.select(preferredDevice.deviceId);
```

### Camera permissions

```typescript
import { useCallStateHooks } from '@stream-io/video-react-sdk';

const { useCameraState } = useCallStateHooks();
const { hasBrowserPermission } = useCameraState();

if (hasBrowserPermission) {
console.log('User has granted camera permissions!');
} else {
console.log('User has denied or not granted camera permissions!');
}
```

### Lobby preview

Here is how to set up a video preview displayed before joining the call:
Expand Down Expand Up @@ -119,6 +134,21 @@ const preferredDevice = devices.find((d) => d.label === 'My Mic');
await microphone.select(preferredDevice.deviceId);
```

### Microphone permissions

```typescript
import { useCallStateHooks } from '@stream-io/video-react-sdk';

const { useMicrophoneState } = useCallStateHooks();
const { hasBrowserPermission } = useMicrophoneState();

if (hasBrowserPermission) {
console.log('User has granted microphone permissions!');
} else {
console.log('User has denied or not granted microphone permissions!');
}
```

### Speaking while muted detection

Our SDK provides a mechanism that can detect whether the user started to speak while being muted.
Expand Down
1 change: 0 additions & 1 deletion packages/react-sdk/src/core/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from './useDevices';
export * from './useTrackElementVisibility';
33 changes: 0 additions & 33 deletions packages/react-sdk/src/core/hooks/useDevices.ts

This file was deleted.

Loading

0 comments on commit a0b3573

Please sign in to comment.