diff --git a/packages/client/src/devices/CameraManager.ts b/packages/client/src/devices/CameraManager.ts index f5ee4efe1f..ed9aa887f9 100644 --- a/packages/client/src/devices/CameraManager.ts +++ b/packages/client/src/devices/CameraManager.ts @@ -1,4 +1,4 @@ -import { Observable } from 'rxjs'; +import { firstValueFrom, Observable } from 'rxjs'; import { Call } from '../Call'; import { CameraDirection, CameraManagerState } from './CameraManagerState'; import { DeviceManager } from './DeviceManager'; @@ -140,7 +140,14 @@ export class CameraManager extends DeviceManager { this.state.status === undefined && this.state.optimisticStatus === undefined; let persistedPreferencesApplied = false; - if (shouldApplyDefaults && this.devicePersistence.enabled) { + const permissionState = await firstValueFrom( + this.state.browserPermissionState$, + ); + if ( + shouldApplyDefaults && + this.devicePersistence.enabled && + permissionState === 'granted' + ) { persistedPreferencesApplied = await this.applyPersistedPreferences(enabledInCallType); } diff --git a/packages/client/src/devices/DeviceManager.ts b/packages/client/src/devices/DeviceManager.ts index e3669df5cf..a9a3381f31 100644 --- a/packages/client/src/devices/DeviceManager.ts +++ b/packages/client/src/devices/DeviceManager.ts @@ -89,9 +89,19 @@ export abstract class DeviceManager< if (this.devicePersistence.enabled) { this.subscriptions.push( createSubscription( - combineLatest([this.state.selectedDevice$, this.state.status$]), - ([selectedDevice, status]) => { - if (!status) return; + combineLatest([ + this.state.selectedDevice$, + this.state.status$, + this.state.browserPermissionState$, + ]), + ([selectedDevice, status, browserPermissionState]) => { + if ( + !status || + (this.isTrackStoppedDueToTrackEnd && status === 'disabled') || + browserPermissionState !== 'granted' + ) + return; + this.persistPreference(selectedDevice, status); }, ), diff --git a/packages/client/src/devices/MicrophoneManager.ts b/packages/client/src/devices/MicrophoneManager.ts index a155be417a..318cf91244 100644 --- a/packages/client/src/devices/MicrophoneManager.ts +++ b/packages/client/src/devices/MicrophoneManager.ts @@ -356,7 +356,14 @@ export class MicrophoneManager extends AudioDeviceManager { - this.persistSpeakerDevicePreference(selectedDevice); - }), + createSubscription( + combineLatest([ + this.state.selectedDevice$, + getAudioBrowserPermission(this.call.tracer).asStateObservable(), + ]), + ([selectedDevice, browserPermissionState]) => { + if (!selectedDevice || browserPermissionState !== 'granted') return; + + this.persistSpeakerDevicePreference(selectedDevice); + }, + ), ); } } diff --git a/packages/client/src/devices/__tests__/CameraManager.test.ts b/packages/client/src/devices/__tests__/CameraManager.test.ts index e775c698fe..4dfdc993c7 100644 --- a/packages/client/src/devices/__tests__/CameraManager.test.ts +++ b/packages/client/src/devices/__tests__/CameraManager.test.ts @@ -306,6 +306,9 @@ describe('CameraManager', () => { }); it('should skip defaults when preferences are applied', async () => { + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('granted'), + ); const devicePersistence = { enabled: true, storageKey: '' }; const persistedManager = new CameraManager(call, devicePersistence); const applySpy = vi @@ -329,6 +332,32 @@ describe('CameraManager', () => { expect(enableSpy).not.toHaveBeenCalled(); }); + it('should skip persisted preferences when permission is not granted', async () => { + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('prompt'), + ); + const devicePersistence = { enabled: true, storageKey: '' }; + const persistedManager = new CameraManager(call, devicePersistence); + const applySpy = vi.spyOn( + persistedManager as never, + 'applyPersistedPreferences', + ); + const enableSpy = vi.spyOn(persistedManager, 'enable'); + + await persistedManager.apply( + fromPartial({ + enabled: true, + target_resolution: { width: 640, height: 480 }, + camera_facing: 'front', + camera_default_on: true, + }), + true, + ); + + expect(applySpy).not.toHaveBeenCalled(); + expect(enableSpy).toHaveBeenCalled(); + }); + it('should not apply defaults when device is not pristine', async () => { manager.state.setStatus('enabled'); const selectDirectionSpy = vi.spyOn(manager, 'selectDirection'); @@ -445,6 +474,9 @@ describe('CameraManager', () => { createVideoStreamForDevice(selectedDevice.deviceId), ); }); + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('granted'), + ); const stressManager = new CameraManager(call, { enabled: true, diff --git a/packages/client/src/devices/__tests__/DeviceManager.test.ts b/packages/client/src/devices/__tests__/DeviceManager.test.ts index 4dede40e76..9b758c3949 100644 --- a/packages/client/src/devices/__tests__/DeviceManager.test.ts +++ b/packages/client/src/devices/__tests__/DeviceManager.test.ts @@ -76,6 +76,9 @@ describe('Device Manager', () => { beforeEach(() => { storageKey = '@test/device-preferences'; localStorageMock = createLocalStorageMock(); + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('granted'), + ); Object.defineProperty(window, 'localStorage', { configurable: true, value: localStorageMock, @@ -455,6 +458,74 @@ describe('Device Manager', () => { }, ]); }); + + it('stores preferences when permission is granted', async () => { + const persistenceEnabledManager = new TestInputMediaDeviceManager( + manager['call'], + { enabled: true, storageKey }, + ); + const listDevicesSpy = vi.spyOn(persistenceEnabledManager, 'listDevices'); + + emitDeviceIds(mockVideoDevices); + persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId); + persistenceEnabledManager.state.setStatus('enabled'); + + expect(readPreferences(storageKey).camera).toBeDefined(); + expect(listDevicesSpy).toHaveBeenCalled(); + expect(readPreferences(storageKey).camera).toEqual([ + { + selectedDeviceId: mockVideoDevices[0].deviceId, + selectedDeviceLabel: mockVideoDevices[0].label, + muted: false, + }, + ]); + }); + + it('does not store preferences when permission is not granted', async () => { + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('prompt'), + ); + const persistenceEnabledManager = new TestInputMediaDeviceManager( + manager['call'], + { enabled: true, storageKey }, + ); + const listDevicesSpy = vi.spyOn(persistenceEnabledManager, 'listDevices'); + + emitDeviceIds(mockVideoDevices); + persistenceEnabledManager.state.setDevice(mockVideoDevices[0].deviceId); + persistenceEnabledManager.state.setStatus('enabled'); + + expect(readPreferences(storageKey).camera).toBeUndefined(); + expect(listDevicesSpy).not.toHaveBeenCalled(); + }); + + it('does not overwrite preferences when track ends unexpectedly', async () => { + const persistenceEnabledManager = new TestInputMediaDeviceManager( + manager['call'], + { enabled: true, storageKey }, + ); + + await persistenceEnabledManager.enable(); + + expect(readPreferences(storageKey).camera).toEqual([ + { + selectedDeviceId: mockVideoDevices[0].deviceId, + selectedDeviceLabel: mockVideoDevices[0].label, + muted: false, + }, + ]); + + const [track] = persistenceEnabledManager.state.mediaStream!.getTracks(); + await ((track as MockTrack).eventHandlers['ended'] as Function)(); + + expect(readPreferences(storageKey).camera).toEqual([ + { + selectedDeviceId: mockVideoDevices[0].deviceId, + selectedDeviceLabel: mockVideoDevices[0].label, + muted: false, + }, + ]); + }); }); describe('applyPersistedPreferences', () => { diff --git a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts index 34cdd262b1..cbaa5d3222 100644 --- a/packages/client/src/devices/__tests__/MicrophoneManager.test.ts +++ b/packages/client/src/devices/__tests__/MicrophoneManager.test.ts @@ -479,6 +479,29 @@ describe('MicrophoneManager', () => { expect(enableSpy).not.toHaveBeenCalled(); }); + it('should skip persisted preferences when permission is not granted', async () => { + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('prompt'), + ); + const devicePersistence = { enabled: true, storageKey: '' }; + const persistedManager = new MicrophoneManager( + call, + devicePersistence, + 'disable-tracks', + ); + const applySpy = vi.spyOn( + persistedManager as never, + 'applyPersistedPreferences', + ); + const enableSpy = vi.spyOn(persistedManager, 'enable'); + + // @ts-expect-error - partial data + await persistedManager.apply({ mic_default_on: true }, true); + + expect(applySpy).not.toHaveBeenCalled(); + expect(enableSpy).toHaveBeenCalled(); + }); + it('should not apply defaults when mic is not pristine', async () => { manager.state.setStatus('enabled'); const applySpy = vi.spyOn(manager as never, 'applyPersistedPreferences'); diff --git a/packages/client/src/devices/__tests__/SpeakerManager.test.ts b/packages/client/src/devices/__tests__/SpeakerManager.test.ts index 65ddfae77e..181b1e0ac5 100644 --- a/packages/client/src/devices/__tests__/SpeakerManager.test.ts +++ b/packages/client/src/devices/__tests__/SpeakerManager.test.ts @@ -37,6 +37,9 @@ describe('SpeakerManager.test', () => { beforeEach(() => { storageKey = '@test/speaker-preferences'; localStorageMock = createLocalStorageMock(); + vi.spyOn(mockBrowserPermission, 'asStateObservable').mockReturnValue( + of('granted'), + ); Object.defineProperty(window, 'localStorage', { configurable: true, value: localStorageMock, @@ -125,6 +128,31 @@ describe('SpeakerManager.test', () => { expect(manager.state.selectedDevice).toBe(''); }); + it('persists speaker selection when permission is granted', async () => { + const persistedManager = new SpeakerManager( + new Call({ + id: '', + type: '', + streamClient: new StreamClient('abc123'), + clientStore: new StreamVideoWriteableStateStore(), + }), + { enabled: true, storageKey }, + ); + const listDevicesSpy = vi.spyOn(persistedManager, 'listDevices'); + const audioOutputDevice = { + deviceId: 'speaker-1', + kind: 'audiooutput', + label: 'Speaker 1', + groupId: 'speaker-group', + } as MediaDeviceInfo; + + emitDeviceIds([audioOutputDevice]); + persistedManager.select(audioOutputDevice.deviceId); + + expect(listDevicesSpy).toHaveBeenCalled(); + expect(persistedManager.state.selectedDevice).toBe('speaker-1'); + }); + describe('apply (web)', () => { it('does nothing when persistence is disabled', () => { const selectSpy = vi.spyOn(manager, 'select');