Skip to content

Commit 3224840

Browse files
Respect viewer microphone consent and recover permission failures (#105)
1 parent 67df841 commit 3224840

15 files changed

Lines changed: 751 additions & 16 deletions

‎apps/mobile/app/(app)/session/[id].tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { VideoViewer } from '@/components/VideoViewer';
2020
import { ChatPanel } from '@/components/ChatPanel';
2121
import { SessionInfo } from '@/components/SessionInfo';
2222
import { ConnectionBadge } from '@/components/ConnectionBadge';
23+
import { MicrophoneNotice } from '@/components/MicrophoneNotice';
2324

2425
export default function SessionScreen() {
2526
const router = useRouter();
@@ -231,7 +232,8 @@ function HostSession({
231232
</View>
232233

233234
{/* Controls */}
234-
<View className="flex-row items-center justify-center gap-4 border-t border-gray-800 px-4 py-3">
235+
<MicrophoneNotice failure={webrtc.micFailure} />
236+
<View className="flex-row flex-wrap items-center justify-center gap-4 border-t border-gray-800 px-4 py-3">
235237
{screenShare.isSharing && (
236238
<TouchableOpacity
237239
onPress={() => {
@@ -351,7 +353,8 @@ function ViewerSession({
351353
</View>
352354

353355
{/* Controls */}
354-
<View className="flex-row items-center justify-center gap-4 border-t border-gray-800 bg-gray-900 px-4 py-3">
356+
<MicrophoneNotice failure={webrtc.micFailure} unmuteRequested={webrtc.unmuteRequested} />
357+
<View className="flex-row flex-wrap items-center justify-center gap-4 border-t border-gray-800 bg-gray-900 px-4 py-3">
355358
{webrtc.connectionState === 'failed' && (
356359
<TouchableOpacity
357360
onPress={webrtc.reconnect}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Mobile microphone consent and recovery
2+
3+
A host mute command can disable the viewer microphone. An unmute command is
4+
only a request: it shows feedback while the viewer remains muted. Only the
5+
viewer's local Unmute button changes that intent. Missing or non-boolean mute
6+
payloads are ignored. Old data-channel callbacks are generation-guarded and
7+
muted intent survives reconnect, including delayed microphone acquisition.
8+
9+
This does not change the existing initially-enabled microphone preference at
10+
join, implement a request/acknowledgement protocol, or change host UI optimistic
11+
mute labels. The host cannot assume a viewer accepted an unmute request.
12+
13+
Host and viewer show listen-only feedback when microphone acquisition fails.
14+
Only recognized permission errors offer a user-initiated native Settings action;
15+
unknown device errors are not called permission denials. Opening Settings can
16+
background the app. The existing foreground-resume path reconnects and
17+
reacquires the mic: after permission is granted, audio follows the existing
18+
initially-on preference or the preserved muted intent. A Settings return is
19+
not a new local-unmute consent gate. There is no permission prompt or acquisition
20+
caused by an incoming unmute message. Ended tracks are never treated as usable
21+
when toggling the microphone.
22+
23+
The pinned pnpm patch for react-native-webrtc 124.0.7 forwards permission-chain
24+
rejections to the caller. Without it, an iOS permission-bridge rejection or a
25+
synchronous capture-start failure leaves acquisition pending with an unhandled
26+
rejection. The patch covers Metro source, CommonJS and ESM builds. Host/viewer
27+
then follow the existing listen-only failure path. Normal user permission
28+
decisions and successful acquisition are unchanged. A permission dialog the
29+
user has not answered can still remain pending; this patch is not a timeout or
30+
native-dialog cancellation mechanism.
31+
32+
`webrtc-permissions.test.ts` executes the installed dependency's JS permission
33+
chain in all three formats, with only native bridge and stream boundaries
34+
replaced. It checks grants, denials, bridge rejection and capture-start throws.
35+
Re-run these tests whenever upgrading WebRTC or changing/removing its patch.
36+
37+
Hook tests cover data-channel payloads, reconnect and permission failures.
38+
Component and real session-route tests check feedback and local button wiring
39+
with native APIs mocked. These do not verify native permission dialogs, device
40+
audio routing, or real WebRTC audio. Before release, check denied/granted mic
41+
permission, host mute/request-unmute, reconnect and Settings on Android and iOS.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from 'react';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { beforeEach, expect, it, vi } from 'vitest';
4+
import { Linking, Platform } from 'react-native';
5+
import { MicrophoneNotice } from './MicrophoneNotice';
6+
vi.mock('react-native', () => ({
7+
View: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
8+
Text: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
9+
TouchableOpacity: ({
10+
children,
11+
onPress,
12+
accessibilityLabel,
13+
}: {
14+
children?: React.ReactNode;
15+
onPress: () => void;
16+
accessibilityLabel: string;
17+
}) => (
18+
<button onClick={onPress} aria-label={accessibilityLabel}>
19+
{children}
20+
</button>
21+
),
22+
Platform: { OS: 'android' },
23+
Linking: { openSettings: vi.fn(async () => {}) },
24+
}));
25+
beforeEach(() => {
26+
Platform.OS = 'android';
27+
});
28+
it('does not open settings automatically and shows requests without enabling a mic', () => {
29+
const { rerender } = render(<MicrophoneNotice failure={null} />);
30+
expect(screen.queryByText(/microphone/i)).toBeNull();
31+
rerender(<MicrophoneNotice failure={null} unmuteRequested />);
32+
expect(screen.getByText(/still muted/)).toBeDefined();
33+
expect(Linking.openSettings).not.toHaveBeenCalled();
34+
});
35+
it.each(['android', 'ios'] as const)(
36+
'shows safe listen-only feedback and settings action on %s',
37+
async (os) => {
38+
Platform.OS = os;
39+
render(<MicrophoneNotice failure="permission" />);
40+
expect(screen.getByText(/Listen-only/)).toBeDefined();
41+
expect(Linking.openSettings).not.toHaveBeenCalled();
42+
vi.mocked(Linking.openSettings).mockRejectedValueOnce(new Error('PRIVATE_ERROR'));
43+
fireEvent.click(screen.getByRole('button', { name: 'Open microphone settings' }));
44+
await waitFor(() => expect(screen.getByText('Could not open Settings.')).toBeDefined());
45+
expect(screen.queryByText('PRIVATE_ERROR')).toBeNull();
46+
}
47+
);
48+
it('does not label an unknown mic failure as denied permission', () => {
49+
render(<MicrophoneNotice failure="unavailable" />);
50+
expect(screen.getByText('Microphone unavailable. Listen-only mode.')).toBeDefined();
51+
expect(screen.queryByRole('button')).toBeNull();
52+
});
53+
it('does not offer native Settings on web', () => {
54+
Platform.OS = 'web';
55+
render(<MicrophoneNotice failure="permission" />);
56+
expect(screen.getByText(/Listen-only/)).toBeDefined();
57+
expect(screen.queryByRole('button')).toBeNull();
58+
expect(Linking.openSettings).not.toHaveBeenCalled();
59+
});
60+
it('clears Settings feedback when microphone state changes and ignores late failures', async () => {
61+
let reject!: (reason: unknown) => void;
62+
vi.mocked(Linking.openSettings).mockReturnValueOnce(
63+
new Promise<void>((_, fail) => {
64+
reject = fail;
65+
})
66+
);
67+
const { rerender } = render(<MicrophoneNotice failure="permission" />);
68+
fireEvent.click(screen.getByRole('button', { name: 'Open microphone settings' }));
69+
rerender(<MicrophoneNotice failure={null} unmuteRequested />);
70+
await act(async () => {
71+
reject(new Error('late'));
72+
});
73+
expect(screen.queryByText('Could not open Settings.')).toBeNull();
74+
rerender(<MicrophoneNotice failure="permission" />);
75+
expect(screen.queryByText('Could not open Settings.')).toBeNull();
76+
vi.mocked(Linking.openSettings).mockRejectedValueOnce(new Error('current'));
77+
fireEvent.click(screen.getByRole('button', { name: 'Open microphone settings' }));
78+
await waitFor(() => expect(screen.getByText('Could not open Settings.')).toBeDefined());
79+
rerender(<MicrophoneNotice failure="unavailable" />);
80+
expect(screen.queryByText('Could not open Settings.')).toBeNull();
81+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { Linking, Platform, Text, TouchableOpacity, View } from 'react-native';
3+
import type { MicrophoneFailure } from '../lib/microphone';
4+
5+
export function MicrophoneNotice({
6+
failure,
7+
unmuteRequested = false,
8+
}: {
9+
failure: MicrophoneFailure | null;
10+
unmuteRequested?: boolean;
11+
}) {
12+
const [settingsFailed, setSettingsFailed] = useState(false);
13+
const settingsContext = useRef({ attempt: 0, active: true });
14+
useEffect(() => {
15+
const context = { attempt: 0, active: true };
16+
settingsContext.current = context;
17+
setSettingsFailed(false);
18+
return () => {
19+
context.active = false;
20+
};
21+
}, [failure]);
22+
if (!failure && !unmuteRequested) return null;
23+
return (
24+
<View className="border-t border-gray-800 bg-gray-900 px-4 py-2">
25+
<Text accessibilityRole="alert" className="text-sm text-gray-200">
26+
{failure === 'permission'
27+
? 'Microphone permission denied. Listen-only mode.'
28+
: failure
29+
? 'Microphone unavailable. Listen-only mode.'
30+
: 'The host requested unmute. Your microphone is still muted.'}
31+
</Text>
32+
{failure === 'permission' && (Platform.OS === 'ios' || Platform.OS === 'android') && (
33+
<TouchableOpacity
34+
accessibilityRole="button"
35+
accessibilityLabel="Open microphone settings"
36+
className="min-h-11 justify-center self-start py-2"
37+
onPress={() => {
38+
setSettingsFailed(false);
39+
const context = settingsContext.current;
40+
const attempt = ++context.attempt;
41+
void Linking.openSettings().catch(() => {
42+
if (context.active && attempt === context.attempt) setSettingsFailed(true);
43+
});
44+
}}
45+
>
46+
<Text className="text-sm text-blue-300">Open Settings</Text>
47+
</TouchableOpacity>
48+
)}
49+
{failure === 'permission' && settingsFailed && (
50+
<Text className="text-sm text-gray-200">Could not open Settings.</Text>
51+
)}
52+
</View>
53+
);
54+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { beforeEach, expect, it, vi } from 'vitest';
4+
import SessionScreen from '../../app/(app)/session/[id]';
5+
6+
const state = vi.hoisted(() => ({
7+
role: 'viewer',
8+
viewer: {
9+
micFailure: null as 'permission' | 'unavailable' | null,
10+
unmuteRequested: true,
11+
hasMic: true,
12+
micEnabled: false,
13+
connectionState: 'connected',
14+
toggleMic: vi.fn(),
15+
disconnect: vi.fn(),
16+
},
17+
host: {
18+
micFailure: 'permission' as const,
19+
hasMic: false,
20+
micEnabled: false,
21+
isHosting: true,
22+
viewerCount: 0,
23+
toggleMic: vi.fn(),
24+
startHosting: vi.fn(),
25+
},
26+
}));
27+
vi.mock('react-native', () => ({
28+
View: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
29+
Text: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
30+
TouchableOpacity: ({
31+
children,
32+
onPress,
33+
accessibilityLabel,
34+
disabled,
35+
}: {
36+
children?: React.ReactNode;
37+
onPress: () => void;
38+
accessibilityLabel?: string;
39+
disabled?: boolean;
40+
}) => (
41+
<button onClick={onPress} aria-label={accessibilityLabel} disabled={disabled}>
42+
{children}
43+
</button>
44+
),
45+
ActivityIndicator: () => <span>Loading</span>,
46+
Alert: { alert: vi.fn() },
47+
Platform: { OS: 'android' },
48+
Linking: { openSettings: vi.fn(async () => {}) },
49+
}));
50+
vi.mock('expo-router', () => ({
51+
useRouter: () => ({ back: vi.fn() }),
52+
useFocusEffect: vi.fn(),
53+
useLocalSearchParams: () => ({ id: 'session-1', role: state.role, participantId: 'viewer-1' }),
54+
}));
55+
vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { id: 'user-1' } }) }));
56+
vi.mock('@/hooks/useWebRTCViewer', () => ({ useWebRTCViewer: () => state.viewer }));
57+
vi.mock('@/hooks/useWebRTCHost', () => ({ useWebRTCHost: () => state.host }));
58+
vi.mock('@/hooks/useScreenShare', () => ({ useScreenShare: () => ({ stop: vi.fn() }) }));
59+
vi.mock('@/hooks/useChat', () => ({ useChat: () => ({ messages: [] }) }));
60+
vi.mock('@/lib/api/sessions', () => ({
61+
sessionApi: { get: vi.fn(async () => ({ data: { join_code: 'ABC' } })) },
62+
}));
63+
vi.mock('@/components/VideoViewer', () => ({ VideoViewer: () => null }));
64+
vi.mock('@/components/ChatPanel', () => ({ ChatPanel: () => null }));
65+
vi.mock('@/components/SessionInfo', () => ({ SessionInfo: () => null }));
66+
vi.mock('@/components/ConnectionBadge', () => ({ ConnectionBadge: () => null }));
67+
beforeEach(() => {
68+
state.role = 'viewer';
69+
state.viewer.hasMic = true;
70+
state.viewer.micFailure = null;
71+
});
72+
it('wires a viewer unmute request to feedback but only the local button to toggleMic', async () => {
73+
render(<SessionScreen />);
74+
await waitFor(() => expect(screen.getByText(/still muted/)).toBeDefined());
75+
expect(state.viewer.toggleMic).not.toHaveBeenCalled();
76+
fireEvent.click(screen.getByRole('button', { name: 'Unmute' }));
77+
expect(state.viewer.toggleMic).toHaveBeenCalledOnce();
78+
});
79+
it('keeps leave available and hides unmute when the viewer has no microphone', async () => {
80+
state.viewer.hasMic = false;
81+
state.viewer.micFailure = 'unavailable';
82+
render(<SessionScreen />);
83+
await waitFor(() =>
84+
expect(screen.getByText('Microphone unavailable. Listen-only mode.')).toBeDefined()
85+
);
86+
expect(screen.queryByRole('button', { name: 'Unmute' })).toBeNull();
87+
expect(screen.getByRole('button', { name: 'Leave' })).toBeDefined();
88+
});
89+
it('shows host permission feedback and keeps ending the session available', async () => {
90+
state.role = 'host';
91+
render(<SessionScreen />);
92+
await waitFor(() => expect(screen.getByText(/Microphone permission denied/)).toBeDefined());
93+
expect(screen.getByRole('button', { name: 'Open microphone settings' })).toBeDefined();
94+
expect(screen.getByRole('button', { name: 'End Session' })).toBeDefined();
95+
expect(state.host.toggleMic).not.toHaveBeenCalled();
96+
});

‎apps/mobile/src/hooks/useWebRTCHost.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ vi.mock('../lib/event-source', () => ({
5454
}));
5555

5656
describe('useWebRTCHost', () => {
57+
it.each(['NotAllowedError', 'DeviceError', 'Error'])(
58+
'exposes a conservative host mic failure for %s',
59+
async (name) => {
60+
vi.mocked(mediaDevices.getUserMedia).mockRejectedValueOnce(
61+
Object.assign(new Error('PRIVATE'), { name })
62+
);
63+
const { result } = renderHook(() =>
64+
useWebRTCHost({ sessionId: 'session-1', hostId: 'host-1' })
65+
);
66+
await act(async () => {
67+
await result.current.startHosting();
68+
});
69+
expect(result.current.micFailure).toBe(
70+
name === 'NotAllowedError' ? 'permission' : 'unavailable'
71+
);
72+
expect(result.current.hasMic).toBe(false);
73+
expect(createEventSource).toHaveBeenCalled();
74+
}
75+
);
5776
beforeEach(() => {
5877
vi.clearAllMocks();
5978
mockEventSources.length = 0;

0 commit comments

Comments
 (0)