Skip to content

Commit 5398932

Browse files
authored
Merge pull request #96 from Resgrid/develop
RU-T46 Bug fixed from testing
2 parents b3174d8 + 169b856 commit 5398932

24 files changed

+2523
-70
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Best Practices:
4848
- Handle errors gracefully and provide user feedback.
4949
- Implement proper offline support.
5050
- Ensure the user interface is intuitive and user-friendly and works seamlessly across different devices and screen sizes.
51+
- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities.
5152

5253
Additional Rules:
5354

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Headset Button PTT (Push-to-Talk) Implementation
2+
3+
This document describes the implementation of AirPods/Bluetooth earbuds button support for Push-to-Talk (PTT) functionality with LiveKit.
4+
5+
## Overview
6+
7+
AirPods and standard Bluetooth earbuds use AVRCP (Audio/Video Remote Control Profile) to communicate media button events. Unlike specialized PTT devices (Aina, B01 Inrico, HYS) that use BLE characteristics, standard Bluetooth audio devices send button events through the system's audio session.
8+
9+
This implementation adds support for using the play/pause button on AirPods and other Bluetooth earbuds to mute/unmute the microphone during a LiveKit voice call.
10+
11+
## Files Modified/Created
12+
13+
### New Files
14+
15+
1. **`src/services/headset-button.service.ts`**
16+
- Core service that handles media button events from AirPods and Bluetooth earbuds
17+
- Listens for `DeviceEventEmitter` events: `HeadsetButtonEvent`, `AudioRouteChange`, `RemoteControlEvent`, `MediaButtonEvent`
18+
- Provides methods for toggling microphone, starting/stopping monitoring
19+
- Supports configurable PTT modes: toggle, push-to-talk, disabled
20+
21+
2. **`src/lib/hooks/use-headset-button-ptt.ts`**
22+
- Custom React hook for easy integration with components
23+
- Auto-starts/stops monitoring based on LiveKit connection status
24+
- Provides `toggleMicrophone`, `startMonitoring`, `stopMonitoring` functions
25+
26+
3. **`src/services/__tests__/headset-button.service.test.ts`**
27+
- Comprehensive unit tests for the headset button service
28+
29+
### Modified Files
30+
31+
1. **`src/stores/app/bluetooth-audio-store.ts`**
32+
- Added `PttMode` type and `HeadsetButtonConfig` interface
33+
- Added `isHeadsetButtonMonitoring` state
34+
- Added `headsetButtonConfig` state
35+
- Added `setIsHeadsetButtonMonitoring` and `setHeadsetButtonConfig` actions
36+
37+
2. **`src/stores/app/livekit-store.ts`**
38+
- Added import for `headsetButtonService`
39+
- Added `toggleMicrophone` and `setMicrophoneEnabled` actions
40+
- Added `startHeadsetButtonMonitoring` and `stopHeadsetButtonMonitoring` actions
41+
- Modified `connectToRoom` to auto-start headset button monitoring
42+
- Modified `disconnectFromRoom` to auto-stop headset button monitoring
43+
44+
3. **`src/translations/en.json`**
45+
- Added translations for headset button PTT feature
46+
47+
4. **`src/translations/es.json`**
48+
- Added Spanish translations for headset button PTT feature
49+
50+
5. **`src/translations/ar.json`**
51+
- Added Arabic translations for headset button PTT feature
52+
53+
## Usage
54+
55+
### Basic Usage with the Hook
56+
57+
```typescript
58+
import { useHeadsetButtonPTT } from '@/lib/hooks/use-headset-button-ptt';
59+
60+
function MyVoiceComponent() {
61+
const {
62+
isMonitoring,
63+
isConnected,
64+
isMuted,
65+
toggleMicrophone,
66+
startMonitoring,
67+
stopMonitoring,
68+
} = useHeadsetButtonPTT();
69+
70+
return (
71+
<View>
72+
<Text>Monitoring: {isMonitoring ? 'Active' : 'Inactive'}</Text>
73+
<Text>Muted: {isMuted ? 'Yes' : 'No'}</Text>
74+
<Button onPress={toggleMicrophone} title="Toggle Mute" />
75+
</View>
76+
);
77+
}
78+
```
79+
80+
### Configuration Options
81+
82+
```typescript
83+
const { updateConfig } = useHeadsetButtonPTT({
84+
autoStartOnConnect: true, // Auto-start when LiveKit connects
85+
autoStopOnDisconnect: true, // Auto-stop when LiveKit disconnects
86+
pttMode: 'toggle', // 'toggle', 'push_to_talk', or 'disabled'
87+
soundFeedback: true, // Play sounds when muting/unmuting
88+
});
89+
90+
// Update configuration at runtime
91+
updateConfig({
92+
playPauseAction: 'toggle_mute', // What to do on play/pause press
93+
doubleClickAction: 'none', // What to do on double click
94+
longPressAction: 'none', // What to do on long press
95+
});
96+
```
97+
98+
### Using the Service Directly
99+
100+
```typescript
101+
import { headsetButtonService } from '@/services/headset-button.service';
102+
103+
// Initialize the service
104+
await headsetButtonService.initialize();
105+
106+
// Start monitoring
107+
headsetButtonService.startMonitoring();
108+
109+
// Toggle microphone
110+
await headsetButtonService.toggleMicrophone();
111+
112+
// Enable microphone
113+
await headsetButtonService.enableMicrophone();
114+
115+
// Disable microphone
116+
await headsetButtonService.disableMicrophone();
117+
118+
// Stop monitoring
119+
headsetButtonService.stopMonitoring();
120+
```
121+
122+
## How It Works
123+
124+
1. When a user connects to a LiveKit room, the headset button monitoring is automatically started.
125+
126+
2. The service listens for media button events from the system:
127+
- On iOS: Remote control events via `AVAudioSession`
128+
- On Android: Media button events via `AudioManager`
129+
130+
3. When the play/pause button is pressed on AirPods or Bluetooth earbuds:
131+
- Single tap: Toggle microphone mute state
132+
- Double tap: Configurable action (default: none)
133+
- Long press: Configurable action (default: none)
134+
135+
4. Sound feedback is played when muting/unmuting (configurable).
136+
137+
5. When the user disconnects from the LiveKit room, monitoring is automatically stopped.
138+
139+
## Supported Button Types
140+
141+
| Button Type | Description |
142+
|------------|-------------|
143+
| `play_pause` | Play/Pause media button (main action button) |
144+
| `hook` | Headset hook button (answer/end call) |
145+
| `next` | Skip to next track |
146+
| `previous` | Skip to previous track |
147+
| `stop` | Stop media playback |
148+
149+
## PTT Modes
150+
151+
| Mode | Description |
152+
|------|-------------|
153+
| `toggle` | Tap to toggle between muted and unmuted |
154+
| `push_to_talk` | Hold to talk, release to mute (not yet implemented) |
155+
| `disabled` | Headset buttons don't affect microphone |
156+
157+
## Native Module Requirements
158+
159+
For full functionality, native modules need to be implemented to:
160+
161+
1. **iOS**: Register for remote control events via `MPRemoteCommandCenter`
162+
2. **Android**: Register a `MediaSession` and handle `MediaButtonReceiver`
163+
164+
The service is designed to receive events from these native modules via `DeviceEventEmitter`. The expected event format:
165+
166+
```typescript
167+
// HeadsetButtonEvent
168+
{
169+
type: 'play_pause' | 'hook' | 'next' | 'previous' | 'stop',
170+
source: 'airpods' | 'bluetooth_headset' | 'wired_headset',
171+
deviceName: string
172+
}
173+
```
174+
175+
## Testing
176+
177+
Run the tests with:
178+
179+
```bash
180+
yarn test -- --testPathPattern="headset-button" --no-coverage
181+
```
182+
183+
## Future Enhancements
184+
185+
1. Implement native modules for iOS and Android to capture media button events
186+
2. Add push-to-talk (hold to talk) mode
187+
3. Add support for volume button controls
188+
4. Add support for custom button mappings
189+
5. Integrate with iOS CallKit for better system integration

src/app/_layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ function RootLayout() {
168168
<Providers>
169169
<Stack>
170170
<Stack.Screen name="(app)" options={{ headerShown: false }} />
171+
<Stack.Screen name="call" options={{ headerShown: false }} />
171172
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
172173
<Stack.Screen name="login/index" options={{ headerShown: false }} />
173174
</Stack>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { render } from '@testing-library/react-native';
2+
import React from 'react';
3+
import { View } from 'react-native';
4+
5+
// Mock expo-router
6+
jest.mock('expo-router', () => {
7+
const React = require('react');
8+
const { View } = require('react-native');
9+
const MockStack = React.forwardRef((props: any, ref: any) => (
10+
<View ref={ref} testID="mock-stack" {...props}>
11+
{props.children}
12+
</View>
13+
));
14+
MockStack.Screen = jest.fn(() => null);
15+
return {
16+
Stack: MockStack,
17+
};
18+
});
19+
20+
import CallIdLayout from '../_layout';
21+
22+
describe('Call [id] Layout', () => {
23+
it('should render without crashing', () => {
24+
const { getByTestId } = render(<CallIdLayout />);
25+
expect(getByTestId('mock-stack')).toBeDefined();
26+
});
27+
28+
it('should configure Stack with proper screenOptions', () => {
29+
const { getByTestId } = render(<CallIdLayout />);
30+
const stack = getByTestId('mock-stack');
31+
expect(stack).toBeDefined();
32+
});
33+
});

src/app/call/[id]/_layout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Stack } from 'expo-router';
2+
import React from 'react';
3+
4+
import { callScreenOptions } from '../shared-options';
5+
6+
export default function CallIdLayout() {
7+
return <Stack screenOptions={callScreenOptions} />;
8+
}

src/app/call/[id]/edit.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export default function EditCall() {
8080
const { id } = useLocalSearchParams();
8181
const callId = Array.isArray(id) ? id[0] : id;
8282
const { callPriorities, callTypes, isLoading: callDataLoading, error: callDataError, fetchCallPriorities, fetchCallTypes } = useCallsStore();
83-
const { call, isLoading: callDetailLoading, error: callDetailError, fetchCallDetail } = useCallDetailStore();
83+
const { call, callExtraData, isLoading: callDetailLoading, error: callDetailError, fetchCallDetail } = useCallDetailStore();
8484
const { config } = useCoreStore();
8585
const { trackEvent } = useAnalytics();
8686
// Safe wrapper for analytics that catches errors and promise rejections
@@ -187,6 +187,44 @@ export default function EditCall() {
187187
const priority = callPriorities.find((p) => p.Id === call.Priority);
188188
const type = callTypes.find((t) => t.Id === call.Type);
189189

190+
// Parse dispatched items from callExtraData
191+
const dispatchedUsers: string[] = [];
192+
const dispatchedGroups: string[] = [];
193+
const dispatchedRoles: string[] = [];
194+
const dispatchedUnits: string[] = [];
195+
196+
if (callExtraData?.Dispatches) {
197+
callExtraData.Dispatches.forEach((dispatch) => {
198+
// Type indicates what kind of entity was dispatched
199+
// Common types: "User", "Unit", "Group", "Role"
200+
const dispatchType = dispatch.Type?.toLowerCase() || '';
201+
const dispatchId = dispatch.Id;
202+
203+
if (dispatchId) {
204+
if (dispatchType === 'user' || dispatchType === 'personnel') {
205+
dispatchedUsers.push(dispatchId);
206+
} else if (dispatchType === 'unit') {
207+
dispatchedUnits.push(dispatchId);
208+
} else if (dispatchType === 'group' || dispatchType === 'station') {
209+
dispatchedGroups.push(dispatchId);
210+
} else if (dispatchType === 'role') {
211+
dispatchedRoles.push(dispatchId);
212+
}
213+
}
214+
});
215+
}
216+
217+
const initialDispatchSelection: DispatchSelection = {
218+
everyone: false,
219+
users: dispatchedUsers,
220+
groups: dispatchedGroups,
221+
roles: dispatchedRoles,
222+
units: dispatchedUnits,
223+
};
224+
225+
// Update local state for dispatch selection
226+
setDispatchSelection(initialDispatchSelection);
227+
190228
reset({
191229
name: call.Name || '',
192230
nature: call.Nature || '',
@@ -201,13 +239,7 @@ export default function EditCall() {
201239
type: type?.Name || '',
202240
contactName: call.ContactName || '',
203241
contactInfo: call.ContactInfo || '',
204-
dispatchSelection: {
205-
everyone: false,
206-
users: [],
207-
groups: [],
208-
roles: [],
209-
units: [],
210-
},
242+
dispatchSelection: initialDispatchSelection,
211243
});
212244

213245
// Set selected location if coordinates exist
@@ -224,7 +256,7 @@ export default function EditCall() {
224256
}
225257
}
226258
}
227-
}, [call, callPriorities, callTypes, reset]);
259+
}, [call, callExtraData, callPriorities, callTypes, reset]);
228260

229261
const onSubmit = async (data: FormValues) => {
230262
try {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { render } from '@testing-library/react-native';
2+
import React from 'react';
3+
import { View } from 'react-native';
4+
5+
// Mock expo-router
6+
jest.mock('expo-router', () => {
7+
const React = require('react');
8+
const { View } = require('react-native');
9+
const MockStack = React.forwardRef((props: any, ref: any) => (
10+
<View ref={ref} testID="mock-stack" {...props}>
11+
{props.children}
12+
</View>
13+
));
14+
MockStack.Screen = jest.fn(() => null);
15+
return {
16+
Stack: MockStack,
17+
};
18+
});
19+
20+
import CallLayout from '../_layout';
21+
22+
describe('Call Layout', () => {
23+
it('should render without crashing', () => {
24+
const { getByTestId } = render(<CallLayout />);
25+
expect(getByTestId('mock-stack')).toBeDefined();
26+
});
27+
28+
it('should configure Stack with proper screenOptions', () => {
29+
const { getByTestId } = render(<CallLayout />);
30+
const stack = getByTestId('mock-stack');
31+
expect(stack).toBeDefined();
32+
});
33+
});

src/app/call/_layout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Stack } from 'expo-router';
2+
import React from 'react';
3+
4+
import { callScreenOptions } from './shared-options';
5+
6+
export default function CallLayout() {
7+
return <Stack screenOptions={callScreenOptions} />;
8+
}

src/app/call/shared-options.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
2+
import { Platform } from 'react-native';
3+
4+
export const callScreenOptions: NativeStackNavigationOptions = {
5+
headerBackVisible: false,
6+
...(Platform.OS === 'android' && {
7+
animation: 'slide_from_right',
8+
}),
9+
};

src/components/calendar/calendar-item-details-sheet.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export const CalendarItemDetailsSheet: React.FC<CalendarItemDetailsSheetProps> =
235235
return (
236236
<CustomBottomSheet isOpen={isOpen} onClose={onClose}>
237237
<VStack style={{ flex: 1 }}>
238-
<ScrollView showsVerticalScrollIndicator={true} contentContainerStyle={{ padding: 24, flexGrow: 1 }}>
238+
<ScrollView style={{ flex: 1 }} showsVerticalScrollIndicator={true} contentContainerStyle={{ padding: 24, paddingBottom: 40 }}>
239239
{/* Header */}
240240
<VStack className="mb-6">
241241
<HStack className="mb-2 items-start justify-between">

0 commit comments

Comments
 (0)