Skip to content

Commit a7723d0

Browse files
committed
🐛(frontend) fix mobile permission detection
Fix camera/microphone permissions not being detected on Android Firefox and iOS Chrome. Extend the polling workaround beyond Safari to all browsers with unreliable permission change events. Add individual error handling for navigator.permissions.query(). Closes #775 #769
1 parent 4f2c4bf commit a7723d0

3 files changed

Lines changed: 108 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to
2222

2323
- 🐛(frontend) disable personal custom background while deleting #1183
2424
- 🐛(frontend) auto-select new custom background when not logged in #1183
25+
- 🐛(frontend) fix mobile permission detection #1157
2526

2627
## [1.11.0] - 2026-03-19
2728

src/frontend/src/features/rooms/hooks/useWatchPermissions.ts

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react'
22
import { permissionsStore } from '@/stores/permissions'
3-
import { isSafari } from '@/utils/livekit'
3+
import { hasUnreliablePermissionsEvents } from '@/utils/livekit'
44

55
const POLLING_TIME = 500
66

@@ -20,66 +20,99 @@ export const useWatchPermissions = () => {
2020
return
2121
}
2222

23-
const [cameraPermission, microphonePermission] = await Promise.all([
24-
navigator.permissions.query({ name: 'camera' }),
25-
navigator.permissions.query({ name: 'microphone' }),
26-
])
23+
let cameraPermission: PermissionStatus | null = null
24+
let microphonePermission: PermissionStatus | null = null
25+
26+
try {
27+
cameraPermission = await navigator.permissions.query({
28+
name: 'camera' as PermissionName,
29+
})
30+
} catch {
31+
permissionsStore.cameraPermission = 'prompt'
32+
}
33+
34+
try {
35+
microphonePermission = await navigator.permissions.query({
36+
name: 'microphone' as PermissionName,
37+
})
38+
} catch {
39+
permissionsStore.microphonePermission = 'prompt'
40+
}
2741

2842
if (isCancelled) return
2943

3044
/**
31-
* Safari Permission API Limitation Workaround
45+
* Browser Permission API Limitation Workaround
3246
*
33-
* Safari has a known issue where permission change events are not reliably fired
34-
* when users interact with permission prompts. This is documented in Apple's forums:
35-
* https://developer.apple.com/forums/thread/757353
47+
* Several browsers have known issues where permission change events are not
48+
* reliably fired when users interact with permission prompts:
49+
* - Safari: https://developer.apple.com/forums/thread/757353
50+
* - All iOS browsers (they use WebKit under Apple's policy)
51+
* - Firefox on Android
3652
*
3753
* The problem:
38-
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
54+
* - When permissions are in 'prompt' state, these browsers may not trigger 'change' events
3955
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
4056
* - This leaves the UI in an inconsistent state showing outdated permission status
4157
*
4258
* The solution:
4359
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
4460
* - Continue polling until both permissions are no longer in 'prompt' state
45-
* - This ensures we catch permission changes even when Safari fails to fire events
61+
* - This ensures we catch permission changes even when browsers fail to fire events
4662
*
47-
* This polling is Safari-specific and only activates when needed to minimize performance impact.
63+
* This polling only activates on affected browsers and when needed to minimize performance impact.
4864
*/
49-
if (
50-
isSafari() &&
51-
(cameraPermission.state === 'prompt' ||
52-
microphonePermission.state === 'prompt')
53-
) {
54-
// Start polling every 1 second if either permission is in 'prompt' state
65+
const needsPolling =
66+
hasUnreliablePermissionsEvents() &&
67+
((cameraPermission?.state ?? 'prompt') === 'prompt' ||
68+
(microphonePermission?.state ?? 'prompt') === 'prompt')
69+
70+
if (needsPolling) {
5571
if (!intervalId) {
5672
intervalId = setInterval(async () => {
5773
try {
58-
const [updatedCamera, updatedMicrophone] = await Promise.all([
59-
navigator.permissions.query({ name: 'camera' }),
60-
navigator.permissions.query({ name: 'microphone' }),
61-
])
74+
let updatedCameraState: PermissionState | null = null
75+
let updatedMicrophoneState: PermissionState | null = null
76+
77+
try {
78+
const updatedCamera = await navigator.permissions.query({
79+
name: 'camera' as PermissionName,
80+
})
81+
updatedCameraState = updatedCamera.state
82+
} catch {
83+
// Permission query not supported, keep current state
84+
}
6285

63-
if (isCancelled) return
86+
try {
87+
const updatedMicrophone = await navigator.permissions.query({
88+
name: 'microphone' as PermissionName,
89+
})
90+
updatedMicrophoneState = updatedMicrophone.state
91+
} catch {
92+
// Permission query not supported, keep current state
93+
}
6494

65-
const cameraChanged =
66-
permissionsStore.cameraPermission !== updatedCamera.state
67-
const microphoneChanged =
68-
permissionsStore.microphonePermission !==
69-
updatedMicrophone.state
95+
if (isCancelled) return
7096

71-
if (cameraChanged) {
72-
permissionsStore.cameraPermission = updatedCamera.state
97+
if (
98+
updatedCameraState &&
99+
permissionsStore.cameraPermission !== updatedCameraState
100+
) {
101+
permissionsStore.cameraPermission = updatedCameraState
73102
}
74103

75-
if (microphoneChanged) {
76-
permissionsStore.microphonePermission =
77-
updatedMicrophone.state
104+
if (
105+
updatedMicrophoneState &&
106+
permissionsStore.microphonePermission !==
107+
updatedMicrophoneState
108+
) {
109+
permissionsStore.microphonePermission = updatedMicrophoneState
78110
}
79111

112+
// Stop polling when both permissions are resolved
80113
if (
81-
updatedCamera.state !== 'prompt' &&
82-
updatedMicrophone.state !== 'prompt'
114+
(updatedCameraState ?? 'prompt') !== 'prompt' &&
115+
(updatedMicrophoneState ?? 'prompt') !== 'prompt'
83116
) {
84117
if (intervalId) {
85118
clearInterval(intervalId)
@@ -95,8 +128,12 @@ export const useWatchPermissions = () => {
95128
}
96129
}
97130

98-
permissionsStore.cameraPermission = cameraPermission.state
99-
permissionsStore.microphonePermission = microphonePermission.state
131+
if (cameraPermission) {
132+
permissionsStore.cameraPermission = cameraPermission.state
133+
}
134+
if (microphonePermission) {
135+
permissionsStore.microphonePermission = microphonePermission.state
136+
}
100137

101138
const handleCameraChange = (e: Event) => {
102139
const target = e.target as PermissionStatus
@@ -105,7 +142,7 @@ export const useWatchPermissions = () => {
105142
if (
106143
intervalId &&
107144
target.state !== 'prompt' &&
108-
microphonePermission.state !== 'prompt'
145+
(microphonePermission?.state ?? 'prompt') !== 'prompt'
109146
) {
110147
clearInterval(intervalId)
111148
intervalId = undefined
@@ -119,22 +156,33 @@ export const useWatchPermissions = () => {
119156
if (
120157
intervalId &&
121158
target.state !== 'prompt' &&
122-
microphonePermission.state !== 'prompt'
159+
(cameraPermission?.state ?? 'prompt') !== 'prompt'
123160
) {
124161
clearInterval(intervalId)
125162
intervalId = undefined
126163
}
127164
}
128165

129-
cameraPermission.addEventListener('change', handleCameraChange)
130-
microphonePermission.addEventListener('change', handleMicrophoneChange)
131-
132-
cleanup = () => {
133-
cameraPermission.removeEventListener('change', handleCameraChange)
134-
microphonePermission.removeEventListener(
166+
if (cameraPermission) {
167+
cameraPermission.addEventListener('change', handleCameraChange)
168+
}
169+
if (microphonePermission) {
170+
microphonePermission.addEventListener(
135171
'change',
136172
handleMicrophoneChange
137173
)
174+
}
175+
176+
cleanup = () => {
177+
if (cameraPermission) {
178+
cameraPermission.removeEventListener('change', handleCameraChange)
179+
}
180+
if (microphonePermission) {
181+
microphonePermission.removeEventListener(
182+
'change',
183+
handleMicrophoneChange
184+
)
185+
}
138186
if (intervalId) {
139187
clearInterval(intervalId)
140188
intervalId = undefined

src/frontend/src/utils/livekit.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ export function isSafari(): boolean {
2222
return getBrowser()?.name === 'Safari'
2323
}
2424

25+
/**
26+
* Detects browsers where the Permissions API change events are unreliable.
27+
* This includes:
28+
* - Safari (known issue with permission change events)
29+
* - All iOS browsers (they all use WebKit under Apple's policy, sharing Safari's limitations)
30+
* - Firefox (unreliable permission change events on Android)
31+
*/
32+
export function hasUnreliablePermissionsEvents(): boolean {
33+
const isIOS =
34+
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
35+
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
36+
return isSafari() || isIOS || isFireFox()
37+
}
38+
2539
export function isLocal(p: Participant) {
2640
return p instanceof LocalParticipant
2741
}

0 commit comments

Comments
 (0)