diff --git a/.agent/rules/rules.md b/.agent/rules/rules.md
new file mode 100644
index 00000000..71687992
--- /dev/null
+++ b/.agent/rules/rules.md
@@ -0,0 +1,66 @@
+You are an expert in TypeScript, React Native, Expo, and Mobile App Development.
+
+Code Style and Structure:
+
+- Write concise, type-safe TypeScript code.
+- Use functional components and hooks over class components.
+- Ensure components are modular, reusable, and maintainable.
+- Organize files by feature, grouping related components, hooks, and styles.
+- This is a mobile application, so ensure all components are mobile friendly and responsive and support both iOS and Android platforms and ensure that the app is optimized for both platforms.
+
+Naming Conventions:
+
+- Use camelCase for variable and function names (e.g., `isFetchingData`, `handleUserInput`).
+- Use PascalCase for component names (e.g., `UserProfile`, `ChatScreen`).
+- Directory and File names should be lowercase and hyphenated (e.g., `user-profile`, `chat-screen`).
+
+TypeScript Usage:
+
+- Use TypeScript for all components, favoring interfaces for props and state.
+- Enable strict typing in `tsconfig.json`.
+- Avoid using `any`; strive for precise types.
+- Utilize `React.FC` for defining functional components with props.
+
+Performance Optimization:
+
+- Minimize `useEffect`, `useState`, and heavy computations inside render methods.
+- Use `React.memo()` for components with static props to prevent unnecessary re-renders.
+- Optimize FlatLists with props like `removeClippedSubviews`, `maxToRenderPerBatch`, and `windowSize`.
+- Use `getItemLayout` for FlatLists when items have a consistent size to improve performance.
+- Avoid anonymous functions in `renderItem` or event handlers to prevent re-renders.
+
+UI and Styling:
+
+- Use consistent styling leveraging `gluestack-ui`. If there isn't a Gluestack component in the `components/ui` directory for the component you are trying to use consistently style it either through `StyleSheet.create()` or Styled Components.
+- Ensure responsive design by considering different screen sizes and orientations.
+- Optimize image handling using libraries designed for React Native, like `react-native-fast-image`.
+
+Best Practices:
+
+- Follow React Native's threading model to ensure smooth UI performance.
+- Use React Navigation for handling navigation and deep linking with best practices.
+- Create and use Jest to test to validate all generated components
+- Generate tests for all components, services and logic generated. Ensure tests run without errors and fix any issues.
+- The app is multi-lingual, so ensure all text is wrapped in `t()` from `react-i18next` for translations with the dictonary files stored in `src/translations`.
+- Ensure support for dark mode and light mode.
+- Ensure the app is accessible, following WCAG guidelines for mobile applications.
+- Make sure the app is optimized for performance, especially for low-end devices.
+- Handle errors gracefully and provide user feedback.
+- Implement proper offline support.
+- Ensure the user interface is intuitive and user-friendly and works seamlessly across different devices and screen sizes.
+- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities.
+
+Additional Rules:
+
+- Use `yarn` as the package manager.
+- Use Expo's secure store for sensitive data
+- Implement proper offline support
+- Use `zustand` for state management
+- Use `react-hook-form` for form handling
+- Use `react-query` for data fetching
+- Use `react-i18next` for internationalization
+- Use `react-native-mmkv` for local storage
+- Use `axios` for API requests
+- Use `@rnmapbox/maps` for maps, mapping or vehicle navigation
+- Use `lucide-react-native` for icons and use those components directly in the markup and don't use the gluestack-ui icon component
+- Use ? : for conditional rendering and not &&
diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml
index 34aefc29..36dbf189 100644
--- a/.github/workflows/react-native-cicd.yml
+++ b/.github/workflows/react-native-cicd.yml
@@ -186,7 +186,7 @@ jobs:
# Create a backup
cp package.json package.json.bak
# Update the package.json
- jq '.version = "7.${{ github.run_number }}.0"' package.json > package.json.tmp && mv package.json.tmp package.json
+ jq '.version = "7.${{ github.run_number }}"' package.json > package.json.tmp && mv package.json.tmp package.json
jq '.versionCode = "7${{ github.run_number }}"' package.json > package.json.tmp && mv package.json.tmp package.json
echo "Updated package.json versions"
cat package.json | grep "version"
@@ -522,7 +522,7 @@ jobs:
# Update the package.json
if [ -f ./package.json ]; then
cp package.json package.json.bak
- jq '.version = "7.${{ github.run_number }}.0"' package.json > package.json.tmp && mv package.json.tmp package.json
+ jq '.version = "7.${{ github.run_number }}"' package.json > package.json.tmp && mv package.json.tmp package.json
echo "Updated package.json version"
cat package.json | grep "version"
else
diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
index daf8441b..e3be75b8 100644
--- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
+++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
@@ -237,6 +237,57 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
)}
+ {/* System Audio Option */}
+
+ {t('bluetooth.audio_output')}
+ {
+ try {
+ useBluetoothAudioStore.getState().setIsConnecting(true);
+ // We use a dummy ID for loading state tracking if needed, or just rely on global loading
+ setConnectingDeviceId('system-audio');
+
+ await bluetoothAudioService.connectToSystemAudio();
+
+ // Update preferred device manually here to ensure UI reflects it immediately
+ // preventing race conditions with store updates
+ await setPreferredDevice({ id: 'system-audio', name: 'System Audio' });
+
+ onClose();
+ } catch (error) {
+ logger.error({ message: 'Failed to select System Audio', context: { error } });
+ showMessage({
+ message: t('bluetooth.connection_error_title') || 'Selection Failed',
+ description: t('bluetooth.system_audio_error') || 'Could not switch to System Audio',
+ type: 'danger',
+ });
+ } finally {
+ useBluetoothAudioStore.getState().setIsConnecting(false);
+ setConnectingDeviceId(null);
+ }
+ }}
+ disabled={!!connectingDeviceId}
+ className={`rounded-lg border p-4 ${preferredDevice?.id === 'system-audio' ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'} ${!!connectingDeviceId ? 'opacity-70' : ''}`}
+ >
+
+
+
+
+
+ System Audio
+
+ AirPods, Car, Wired Headset
+
+
+ {preferredDevice?.id === 'system-audio' && (
+
+ {t('bluetooth.selected')}
+
+ )}
+
+
+
+
{/* Scan Button */}
{t('bluetooth.available_devices')}
diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts
index 05637a93..6f6159be 100644
--- a/src/services/__tests__/bluetooth-audio.service.test.ts
+++ b/src/services/__tests__/bluetooth-audio.service.test.ts
@@ -51,6 +51,18 @@ jest.mock('@/services/audio.service', () => ({
},
}));
+jest.mock('@/stores/app/livekit-store', () => {
+ const actions = {
+ toggleMicrophone: jest.fn(),
+ setMicrophoneEnabled: jest.fn(),
+ };
+ return {
+ useLiveKitStore: {
+ getState: jest.fn(() => actions),
+ },
+ };
+});
+
import { bluetoothAudioService } from '../bluetooth-audio.service';
describe('BluetoothAudioService Refactoring', () => {
@@ -159,4 +171,28 @@ describe('BluetoothAudioService Refactoring', () => {
expect(service.hasAttemptedPreferredDeviceConnection).toBe(false);
});
});
+ describe('Microphone Control Delegation', () => {
+ it('should delegate mute toggle to livekitStore', async () => {
+ const service = bluetoothAudioService as any;
+
+ // Call private method via casting to any
+ await service.handleMuteToggle();
+
+ const storeMock = require('@/stores/app/livekit-store').useLiveKitStore.getState();
+ expect(storeMock.toggleMicrophone).toHaveBeenCalled();
+ });
+
+ it('should delegate setMicrophoneEnabled to livekitStore', async () => {
+ const service = bluetoothAudioService as any;
+
+ // Call private method via casting to any
+ await service.setMicrophoneEnabled(true);
+
+ const storeMock = require('@/stores/app/livekit-store').useLiveKitStore.getState();
+ expect(storeMock.setMicrophoneEnabled).toHaveBeenCalledWith(true);
+
+ await service.setMicrophoneEnabled(false);
+ expect(storeMock.setMicrophoneEnabled).toHaveBeenCalledWith(false);
+ });
+ });
});
diff --git a/src/services/__tests__/media-button.service.test.ts b/src/services/__tests__/media-button.service.test.ts
deleted file mode 100644
index c32d17b5..00000000
--- a/src/services/__tests__/media-button.service.test.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
-
-import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
-import { useLiveKitStore } from '@/stores/app/livekit-store';
-
-// Mock dependencies
-jest.mock('react-native', () => ({
- NativeModules: {
- MediaButtonModule: {
- startListening: jest.fn(),
- stopListening: jest.fn(),
- },
- },
- NativeEventEmitter: jest.fn().mockImplementation(() => ({
- addListener: jest.fn().mockReturnValue({ remove: jest.fn() }),
- removeAllListeners: jest.fn(),
- })),
- DeviceEventEmitter: {
- addListener: jest.fn().mockReturnValue({ remove: jest.fn() }),
- removeAllListeners: jest.fn(),
- },
- Platform: {
- OS: 'ios',
- },
-}));
-
-jest.mock('@/lib/logging', () => ({
- logger: {
- info: jest.fn(),
- debug: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
- },
-}));
-
-jest.mock('@/services/audio.service', () => ({
- audioService: {
- playStartTransmittingSound: jest.fn().mockResolvedValue(undefined),
- playStopTransmittingSound: jest.fn().mockResolvedValue(undefined),
- },
-}));
-
-// Mock the stores
-const mockBluetoothAudioStore = {
- addButtonEvent: jest.fn(),
- setLastButtonAction: jest.fn(),
- mediaButtonPTTSettings: {
- enabled: true,
- pttMode: 'toggle',
- usePlayPauseForPTT: true,
- doubleTapAction: 'toggle_mute',
- doubleTapTimeoutMs: 400,
- },
-};
-
-const mockLiveKitStore = {
- currentRoom: {
- localParticipant: {
- isMicrophoneEnabled: false,
- setMicrophoneEnabled: jest.fn().mockResolvedValue(undefined),
- },
- },
-};
-
-jest.mock('@/stores/app/bluetooth-audio-store', () => ({
- useBluetoothAudioStore: {
- getState: () => mockBluetoothAudioStore,
- },
-}));
-
-jest.mock('@/stores/app/livekit-store', () => ({
- useLiveKitStore: {
- getState: () => mockLiveKitStore,
- },
-}));
-
-// Import after mocks are set up
-import { mediaButtonService, type MediaButtonPTTSettings } from '../media-button.service';
-import { audioService } from '@/services/audio.service';
-
-describe('MediaButtonService', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- // Reset service state
- mediaButtonService.destroy();
- });
-
- describe('getInstance', () => {
- it('should return a singleton instance', () => {
- const instance1 = mediaButtonService;
- const instance2 = mediaButtonService;
- expect(instance1).toBe(instance2);
- });
- });
-
- describe('initialize', () => {
- it('should initialize the service successfully', async () => {
- await mediaButtonService.initialize();
- expect(mediaButtonService.isServiceInitialized()).toBe(true);
- });
-
- it('should not reinitialize if already initialized', async () => {
- await mediaButtonService.initialize();
- await mediaButtonService.initialize();
- expect(mediaButtonService.isServiceInitialized()).toBe(true);
- });
-
- it('should check for native module availability', async () => {
- await mediaButtonService.initialize();
- expect(mediaButtonService.isNativeModuleAvailable()).toBe(true);
- });
- });
-
- describe('settings management', () => {
- it('should return default settings', () => {
- const settings = mediaButtonService.getSettings();
- expect(settings.enabled).toBe(true);
- expect(settings.pttMode).toBe('toggle');
- expect(settings.usePlayPauseForPTT).toBe(true);
- });
-
- it('should update settings', () => {
- const newSettings: Partial = {
- enabled: false,
- pttMode: 'push_to_talk',
- };
- mediaButtonService.updateSettings(newSettings);
-
- const settings = mediaButtonService.getSettings();
- expect(settings.enabled).toBe(false);
- expect(settings.pttMode).toBe('push_to_talk');
- });
-
- it('should enable/disable via setEnabled', () => {
- mediaButtonService.setEnabled(false);
- expect(mediaButtonService.getSettings().enabled).toBe(false);
-
- mediaButtonService.setEnabled(true);
- expect(mediaButtonService.getSettings().enabled).toBe(true);
- });
- });
-
- describe('destroy', () => {
- it('should cleanup resources on destroy', async () => {
- await mediaButtonService.initialize();
- mediaButtonService.destroy();
- expect(mediaButtonService.isServiceInitialized()).toBe(false);
- });
-
- it('should stop listening on native module when destroyed', async () => {
- await mediaButtonService.initialize();
- mediaButtonService.destroy();
- expect(NativeModules.MediaButtonModule.stopListening).toHaveBeenCalled();
- });
- });
-
- describe('PTT actions', () => {
- beforeEach(async () => {
- await mediaButtonService.initialize();
- mediaButtonService.setEnabled(true);
- });
-
- it('should enable microphone when PTT is triggered and mic is disabled', async () => {
- mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = false;
-
- await mediaButtonService.enableMicrophone();
-
- expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
- expect(audioService.playStartTransmittingSound).toHaveBeenCalled();
- expect(mockBluetoothAudioStore.addButtonEvent).toHaveBeenCalledWith(
- expect.objectContaining({
- button: 'ptt_start',
- })
- );
- });
-
- it('should not enable microphone if already enabled', async () => {
- mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = true;
-
- await mediaButtonService.enableMicrophone();
-
- expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
- });
-
- it('should disable microphone when PTT is released', async () => {
- mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = true;
-
- await mediaButtonService.disableMicrophone();
-
- expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
- expect(audioService.playStopTransmittingSound).toHaveBeenCalled();
- expect(mockBluetoothAudioStore.addButtonEvent).toHaveBeenCalledWith(
- expect.objectContaining({
- button: 'ptt_stop',
- })
- );
- });
-
- it('should not disable microphone if already disabled', async () => {
- mockLiveKitStore.currentRoom.localParticipant.isMicrophoneEnabled = false;
-
- await mediaButtonService.disableMicrophone();
-
- expect(mockLiveKitStore.currentRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
- });
- });
-
- describe('when no LiveKit room is active', () => {
- beforeEach(async () => {
- await mediaButtonService.initialize();
- mediaButtonService.setEnabled(true);
- });
-
- it('should not throw error when enabling mic without room', async () => {
- const originalRoom = mockLiveKitStore.currentRoom;
- (mockLiveKitStore as any).currentRoom = null;
-
- await expect(mediaButtonService.enableMicrophone()).resolves.not.toThrow();
-
- (mockLiveKitStore as any).currentRoom = originalRoom;
- });
-
- it('should not throw error when disabling mic without room', async () => {
- const originalRoom = mockLiveKitStore.currentRoom;
- (mockLiveKitStore as any).currentRoom = null;
-
- await expect(mediaButtonService.disableMicrophone()).resolves.not.toThrow();
-
- (mockLiveKitStore as any).currentRoom = originalRoom;
- });
- });
-
- describe('when PTT is disabled', () => {
- beforeEach(async () => {
- await mediaButtonService.initialize();
- mediaButtonService.setEnabled(false);
- });
-
- it('should not process button events when disabled', async () => {
- // The handleMediaButtonEvent is private, so we test via settings check
- const settings = mediaButtonService.getSettings();
- expect(settings.enabled).toBe(false);
- });
- });
-});
-
-describe('MediaButtonService - Platform specific', () => {
- describe('iOS', () => {
- beforeEach(() => {
- (Platform as any).OS = 'ios';
- jest.clearAllMocks();
- mediaButtonService.destroy();
- });
-
- it('should setup iOS event listeners when native module is available', async () => {
- await mediaButtonService.initialize();
- expect(NativeEventEmitter).toHaveBeenCalled();
- });
-
- it('should call startListening on native module', async () => {
- await mediaButtonService.initialize();
- expect(NativeModules.MediaButtonModule.startListening).toHaveBeenCalled();
- });
- });
-
- describe('Android', () => {
- beforeEach(() => {
- (Platform as any).OS = 'android';
- jest.clearAllMocks();
- mediaButtonService.destroy();
- });
-
- it('should setup Android event listeners when native module is available', async () => {
- await mediaButtonService.initialize();
- expect(NativeEventEmitter).toHaveBeenCalled();
- });
-
- it('should call startListening on native module', async () => {
- await mediaButtonService.initialize();
- expect(NativeModules.MediaButtonModule.startListening).toHaveBeenCalled();
- });
- });
-});
diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts
index 8c5cc8e2..587811ee 100644
--- a/src/services/bluetooth-audio.service.ts
+++ b/src/services/bluetooth-audio.service.ts
@@ -132,6 +132,14 @@ class BluetoothAudioService {
// Set the preferred device in the store
useBluetoothAudioStore.getState().setPreferredDevice(preferredDevice);
+ if (preferredDevice.id === 'system-audio') {
+ logger.info({
+ message: 'Preferred device is System Audio, ensuring no specialized device is connected',
+ });
+ // We are already in system audio mode by default if no device is connected
+ return;
+ }
+
// Try to connect directly to the preferred device
try {
await this.connectToDevice(preferredDevice.id);
@@ -580,6 +588,32 @@ class BluetoothAudioService {
}
}
+ private getDeviceType(device: Device): 'specialized' | 'system' {
+ const advertisingData = device.advertising;
+ const serviceUUIDs = advertisingData?.serviceUUIDs || [];
+
+ // Check for specialized PTT service UUIDs
+ const isSpecialized = serviceUUIDs.some((uuid: string) => {
+ return [
+ AINA_HEADSET_SERVICE,
+ B01INRICO_HEADSET_SERVICE,
+ HYS_HEADSET_SERVICE
+ ].some(specialized => this.areUuidsEqual(uuid, specialized));
+ });
+
+ if (isSpecialized) {
+ return 'specialized';
+ }
+
+ // Check by name for known specialized devices if UUID check fails
+ const name = device.name?.toLowerCase() || '';
+ if (name.includes('aina') || name.includes('inrico') || name.includes('hys')) {
+ return 'specialized';
+ }
+
+ return 'system';
+ }
+
private decodeServiceDataString(data: string): Buffer {
try {
// Service data can be in various formats: hex string, base64, etc.
@@ -773,6 +807,7 @@ class BluetoothAudioService {
hasAudioCapability: true,
supportsMicrophoneControl: this.supportsMicrophoneControl(device),
device,
+ type: this.getDeviceType(device),
};
logger.info({
@@ -900,8 +935,21 @@ class BluetoothAudioService {
hasAudioCapability: true,
supportsMicrophoneControl: this.supportsMicrophoneControl(device),
device,
+ type: this.getDeviceType(device),
});
+ // Special handling for specialized PTT devices to prevent mute loops
+ if (this.getDeviceType(device) === 'specialized') {
+ callKeepService.removeMuteListener();
+ logger.info({
+ message: 'Specialized PTT device connected - CallKeep mute listener removed',
+ context: { deviceId },
+ });
+ } else {
+ // Ensure listener is active for system devices
+ callKeepService.restoreMuteListener();
+ }
+
// Set up button event monitoring with peripheral info
await this.setupButtonEventMonitoring(device, peripheralInfo);
@@ -944,6 +992,46 @@ class BluetoothAudioService {
}
}
+ async connectToSystemAudio(): Promise {
+ try {
+ logger.info({ message: 'Switching to System Audio' });
+
+ // Disconnect any currently connected specialized device
+ if (this.connectedDevice) {
+ try {
+ await BleManager.disconnect(this.connectedDevice.id);
+ } catch (error) {
+ logger.warn({ message: 'Error disconnecting device for System Audio switch', context: { error } });
+ }
+ this.connectedDevice = null;
+ useBluetoothAudioStore.getState().setConnectedDevice(null);
+ }
+
+ // Ensure system audio state
+ callKeepService.restoreMuteListener();
+
+ // Revert LiveKit audio routing explicitly to be safe
+ this.revertLiveKitAudioRouting();
+
+ // Update preferred device
+ const systemAudioDevice = { id: 'system-audio', name: 'System Audio' };
+ useBluetoothAudioStore.getState().setPreferredDevice(systemAudioDevice);
+
+ // Save to storage (implied by previous code loading it from storage, but we need to save it too?
+ // usage of usePreferredBluetoothDevice hook elsewhere handles saving,
+ // but here we are in service. The UI calls logic that saves it eventually
+ // or we should do it here if we want persistence.)
+ // The service reads from storage using require('@/lib/storage'), so we should probably save it too if we want it to persist.
+ // However, the UI calls setPreferredDevice from the hook which likely saves it.
+ // We will let the UI handle the persistence call or add it here if needed.
+ // For now, updating the store is enough for the session.
+
+ } catch (error) {
+ logger.error({ message: 'Failed to switch to System Audio', context: { error } });
+ throw error;
+ }
+ }
+
private handleDeviceDisconnected(args: { peripheral: string }): void {
logger.info({
message: '[DISCONNECT EVENT] Bluetooth audio device disconnected',
@@ -961,6 +1049,9 @@ class BluetoothAudioService {
// Revert LiveKit audio routing to default
this.revertLiveKitAudioRouting();
+
+ // Restore CallKeep mute listener when device disconnects
+ callKeepService.restoreMuteListener();
}
}
@@ -1546,74 +1637,23 @@ class BluetoothAudioService {
}
private async handleMuteToggle(): Promise {
- const liveKitStore = useLiveKitStore.getState();
- if (liveKitStore.currentRoom) {
- const currentMuteState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
-
- try {
- await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(currentMuteState);
-
- logger.info({
- message: 'Microphone toggled via Bluetooth button',
- context: { enabled: currentMuteState },
- });
-
- useBluetoothAudioStore.getState().setLastButtonAction({
- action: currentMuteState ? 'unmute' : 'mute',
- timestamp: Date.now(),
- });
-
- if (currentMuteState) {
- await audioService.playStartTransmittingSound();
- } else {
- await audioService.playStopTransmittingSound();
- }
- } catch (error) {
- logger.error({
- message: 'Failed to toggle microphone via Bluetooth button',
- context: { error },
- });
- }
+ try {
+ await useLiveKitStore.getState().toggleMicrophone();
+ } catch (error) {
+ logger.error({
+ message: 'Failed to toggle microphone via Bluetooth button',
+ context: { error },
+ });
}
}
private async setMicrophoneEnabled(enabled: boolean): Promise {
- const liveKitStore = useLiveKitStore.getState();
- if (liveKitStore.currentRoom) {
- const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
-
- try {
- // Skip if already in the desired state
- if (enabled && currentMicEnabled) return; // already enabled
- if (!enabled && !currentMicEnabled) return; // already disabled
-
- await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(enabled);
-
- logger.info({
- message: 'Microphone set via Bluetooth PTT button',
- context: { enabled },
- });
-
- useBluetoothAudioStore.getState().setLastButtonAction({
- action: enabled ? 'unmute' : 'mute',
- timestamp: Date.now(),
- });
-
- if (enabled) {
- await audioService.playStartTransmittingSound();
- } else {
- await audioService.playStopTransmittingSound();
- }
- } catch (error) {
- logger.error({
- message: 'Failed to set microphone via Bluetooth PTT button',
- context: { error, enabled },
- });
- }
- } else {
- logger.warn({
- message: 'Cannot set microphone - no active LiveKit room',
- context: { enabled },
+ try {
+ await useLiveKitStore.getState().setMicrophoneEnabled(enabled);
+ } catch (error) {
+ logger.error({
+ message: 'Failed to set microphone via Bluetooth PTT button',
+ context: { error, enabled },
});
}
}
diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts
index d78c7ba1..b4c52b86 100644
--- a/src/services/callkeep.service.android.ts
+++ b/src/services/callkeep.service.android.ts
@@ -20,6 +20,7 @@ export class CallKeepService {
private isSetup = false;
private isCallActive = false;
private muteStateCallback: ((muted: boolean) => void) | null = null;
+ private endCallCallback: (() => void) | null = null;
private constructor() {}
@@ -206,6 +207,13 @@ export class CallKeepService {
this.muteStateCallback = callback;
}
+ /**
+ * Set a callback to handle end call events from CallKit
+ */
+ setEndCallCallback(callback: (() => void) | null): void {
+ this.endCallCallback = callback;
+ }
+
/**
* Externally lock/ignore mute events (No-op on Android for now)
*/
@@ -213,6 +221,20 @@ export class CallKeepService {
// No-op on Android
}
+ /**
+ * Remove the mute listener (interface compatibility)
+ */
+ removeMuteListener(): void {
+ // No-op on Android for now
+ }
+
+ /**
+ * Restore the mute listener (interface compatibility)
+ */
+ restoreMuteListener(): void {
+ // No-op on Android for now
+ }
+
/**
* Check if there's an active CallKit call
*/
@@ -242,6 +264,10 @@ export class CallKeepService {
currentCallUUID = null;
this.isCallActive = false;
}
+
+ if (this.endCallCallback) {
+ this.endCallCallback();
+ }
});
// Call answered (not typically used for outgoing calls, but good to handle)
diff --git a/src/services/callkeep.service.ios.ts b/src/services/callkeep.service.ios.ts
index 491a1392..46cc47c7 100644
--- a/src/services/callkeep.service.ios.ts
+++ b/src/services/callkeep.service.ios.ts
@@ -21,6 +21,7 @@ export class CallKeepService {
private isSetup = false;
private isCallActive = false;
private muteStateCallback: ((muted: boolean) => void) | null = null;
+ private endCallCallback: (() => void) | null = null;
private lastMuteEventTime: number = 0;
private muteEventStormEndTime: number = 0;
private ignoreEventsUntil: number = 0;
@@ -215,6 +216,13 @@ export class CallKeepService {
this.muteStateCallback = callback;
}
+ /**
+ * Set a callback to handle end call events from CallKit
+ */
+ setEndCallCallback(callback: (() => void) | null): void {
+ this.endCallCallback = callback;
+ }
+
/**
* Externally lock/ignore mute events for a duration.
* Useful when we know a PTT button is being pressed and want to ignore system side-effects.
@@ -273,6 +281,10 @@ export class CallKeepService {
currentCallUUID = null;
this.isCallActive = false;
}
+
+ if (this.endCallCallback) {
+ this.endCallCallback();
+ }
});
// Call answered (not typically used for outgoing calls, but good to handle)
@@ -283,65 +295,89 @@ export class CallKeepService {
});
});
- // Mute/unmute events
- RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }: { muted: boolean; callUUID: string }) => {
- const now = Date.now();
+ // Mute/unmute events
+ RNCallKeep.addEventListener('didPerformSetMutedCallAction', this.handleMutedCallAction);
+ }
- // Check for external lock (e.g. set by PTT button logic)
- if (now < this.ignoreEventsUntil) {
- logger.debug({
- message: 'Ignored CallKeep mute state change (external lock)',
- context: { muted, callUUID, lockedUntil: this.ignoreEventsUntil },
- });
- return;
- }
+ /**
+ * Remove the mute listener to prevent conflicts with specialized PTT devices
+ */
+ removeMuteListener(): void {
+ RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
+ logger.debug({
+ message: 'CallKeep mute listener removed',
+ });
+ }
- const timeSinceLastEvent = now - this.lastMuteEventTime;
- this.lastMuteEventTime = now;
+ /**
+ * Restore the mute listener
+ */
+ restoreMuteListener(): void {
+ // Remove first to ensure no duplicates
+ RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
+ RNCallKeep.addEventListener('didPerformSetMutedCallAction', this.handleMutedCallAction);
+ logger.debug({
+ message: 'CallKeep mute listener restored',
+ });
+ }
- // Storm Detection / Spam Protection
- // If events are coming in rapidly (< 500ms), we consider it a "storm" (e.g. faulty headset or HFP conflict)
- // We block ALL events during a storm and extend the block window as long as the storm continues.
+ private handleMutedCallAction = ({ muted, callUUID }: { muted: boolean; callUUID: string }) => {
+ const now = Date.now();
- // If the delta is small, it's definitely spam/storm part
- if (timeSinceLastEvent < 500) {
- this.muteEventStormEndTime = now + 800; // Block for 800ms from this event
+ // Check for external lock (e.g. set by PTT button logic)
+ if (now < this.ignoreEventsUntil) {
+ logger.debug({
+ message: 'Ignored CallKeep mute state change (external lock)',
+ context: { muted, callUUID, lockedUntil: this.ignoreEventsUntil },
+ });
+ return;
+ }
- logger.debug({
- message: 'Ignored CallKeep mute state change (storm detected)',
- context: { muted, callUUID, timeSinceLastEvent },
- });
- return;
- }
+ const timeSinceLastEvent = now - this.lastMuteEventTime;
+ this.lastMuteEventTime = now;
- // If we are still within the storm cooldown window (even if this specific delta was > 500, though unlikely given logic above)
- if (now < this.muteEventStormEndTime) {
- this.muteEventStormEndTime = now + 800; // Extend block
- logger.debug({
- message: 'Ignored CallKeep mute state change (storm cooldown)',
- context: { muted, callUUID, stormEndsAt: this.muteEventStormEndTime },
- });
- return;
- }
+ // Storm Detection / Spam Protection
+ // If events are coming in rapidly (< 500ms), we consider it a "storm" (e.g. faulty headset or HFP conflict)
+ // We block ALL events during a storm and extend the block window as long as the storm continues.
+
+ // If the delta is small, it's definitely spam/storm part
+ if (timeSinceLastEvent < 500) {
+ this.muteEventStormEndTime = now + 800; // Block for 800ms from this event
logger.debug({
- message: 'CallKeep mute state changed',
- context: { muted, callUUID },
+ message: 'Ignored CallKeep mute state change (storm detected)',
+ context: { muted, callUUID, timeSinceLastEvent },
});
+ return;
+ }
- // Call the registered callback if available
- if (this.muteStateCallback) {
- try {
- this.muteStateCallback(muted);
- } catch (error) {
- logger.warn({
- message: 'Failed to execute mute state callback',
- context: { error, muted, callUUID },
- });
- }
- }
+ // If we are still within the storm cooldown window (even if this specific delta was > 500, though unlikely given logic above)
+ if (now < this.muteEventStormEndTime) {
+ this.muteEventStormEndTime = now + 800; // Extend block
+ logger.debug({
+ message: 'Ignored CallKeep mute state change (storm cooldown)',
+ context: { muted, callUUID, stormEndsAt: this.muteEventStormEndTime },
+ });
+ return;
+ }
+
+ logger.debug({
+ message: 'CallKeep mute state changed',
+ context: { muted, callUUID },
});
- }
+
+ // Call the registered callback if available
+ if (this.muteStateCallback) {
+ try {
+ this.muteStateCallback(muted);
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to execute mute state callback',
+ context: { error, muted, callUUID },
+ });
+ }
+ }
+ };
/**
* Generate a UUID for CallKeep calls
diff --git a/src/services/media-button.service.ts b/src/services/media-button.service.ts
deleted file mode 100644
index 36e4445f..00000000
--- a/src/services/media-button.service.ts
+++ /dev/null
@@ -1,491 +0,0 @@
-import { DeviceEventEmitter, NativeEventEmitter, NativeModules, Platform } from 'react-native';
-
-import { logger } from '@/lib/logging';
-import { audioService } from '@/services/audio.service';
-import { type AudioButtonEvent, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
-import { createDefaultPTTSettings, type MediaButtonPTTSettings, type PTTMode } from '@/types/ptt';
-
-// Re-export PTT types for backwards compatibility
-export { type MediaButtonPTTSettings, type PTTMode };
-
-// Lazy import to break dependency cycle with livekit-store
-const getLiveKitStore = () => require('@/stores/app/livekit-store').useLiveKitStore;
-
-// Media button event types
-export type MediaButtonEventType = 'play' | 'pause' | 'playPause' | 'stop' | 'next' | 'previous' | 'togglePlayPause';
-
-export interface MediaButtonEvent {
- type: MediaButtonEventType;
- timestamp: number;
- source?: 'airpods' | 'bluetooth_earbuds' | 'wired_headset' | 'unknown';
-}
-
-// Try to get the native module (will be null if not installed)
-const { MediaButtonModule } = NativeModules;
-
-class MediaButtonService {
- private static instance: MediaButtonService;
- private isInitialized = false;
- private eventListeners: { remove: () => void }[] = [];
- private settings: MediaButtonPTTSettings = createDefaultPTTSettings();
- private lastPressTimestamp: number = 0;
- private doubleTapTimer: ReturnType | null = null;
- private pendingSingleTap: boolean = false;
-
- private constructor() {}
-
- static getInstance(): MediaButtonService {
- if (!MediaButtonService.instance) {
- MediaButtonService.instance = new MediaButtonService();
- }
- return MediaButtonService.instance;
- }
-
- /**
- * Initialize the media button service
- * Sets up event listeners for media button presses from AirPods/earbuds
- */
- async initialize(): Promise {
- if (this.isInitialized) {
- logger.debug({
- message: 'Media button service already initialized',
- });
- return;
- }
-
- try {
- logger.info({
- message: 'Initializing Media Button Service for AirPods/earbuds PTT support',
- });
-
- this.setupEventListeners();
- this.isInitialized = true;
-
- logger.info({
- message: 'Media Button Service initialized successfully',
- context: { platform: Platform.OS },
- });
- } catch (error) {
- logger.error({
- message: 'Failed to initialize Media Button Service',
- context: { error },
- });
- throw error;
- }
- }
-
- /**
- * Setup event listeners for media button events
- * On iOS: Uses MPRemoteCommandCenter via native module or CallKeep
- * On Android: Uses MediaSession via native module
- */
- private setupEventListeners(): void {
- if (Platform.OS === 'ios') {
- this.setupIOSEventListeners();
- } else if (Platform.OS === 'android') {
- this.setupAndroidEventListeners();
- }
- }
-
- /**
- * Setup iOS-specific event listeners
- * iOS AirPods/earbuds send media control events through MPRemoteCommandCenter
- */
- private setupIOSEventListeners(): void {
- // If native module is available, use it
- if (MediaButtonModule) {
- const eventEmitter = new NativeEventEmitter(MediaButtonModule);
-
- const playPauseListener = eventEmitter.addListener('onMediaButtonPlayPause', () => {
- this.handleMediaButtonEvent('playPause');
- });
- this.eventListeners.push(playPauseListener);
-
- const playListener = eventEmitter.addListener('onMediaButtonPlay', () => {
- this.handleMediaButtonEvent('play');
- });
- this.eventListeners.push(playListener);
-
- const pauseListener = eventEmitter.addListener('onMediaButtonPause', () => {
- this.handleMediaButtonEvent('pause');
- });
- this.eventListeners.push(pauseListener);
-
- const toggleListener = eventEmitter.addListener('onMediaButtonToggle', () => {
- this.handleMediaButtonEvent('togglePlayPause');
- });
- this.eventListeners.push(toggleListener);
-
- // Enable the native module to start receiving events
- MediaButtonModule.startListening?.();
-
- logger.debug({
- message: 'iOS media button listeners setup via native module',
- });
- } else {
- // Fallback: Use CallKeep mute events (already handled by CallKeep service)
- // This is a limited fallback since CallKeep only provides mute state changes
- // from the iOS Call UI, not from AirPods button presses directly
- logger.warn({
- message: 'MediaButtonModule not available on iOS - AirPods PTT may be limited',
- context: {
- suggestion: 'Install the MediaButtonModule native module for full AirPods PTT support',
- },
- });
-
- // We can still listen for generic audio session events through DeviceEventEmitter
- // Some libraries emit events that we can hook into
- const audioRouteListener = DeviceEventEmitter.addListener('audioRouteChanged', (event: { reason: string }) => {
- logger.debug({
- message: 'Audio route changed',
- context: { event },
- });
- });
- this.eventListeners.push(audioRouteListener);
- }
- }
-
- /**
- * Setup Android-specific event listeners
- * Android uses MediaSession callbacks for headset button events
- */
- private setupAndroidEventListeners(): void {
- if (MediaButtonModule) {
- const eventEmitter = new NativeEventEmitter(MediaButtonModule);
-
- // MediaSession callback events
- const mediaButtonListener = eventEmitter.addListener('onMediaButtonEvent', (event: { keyCode: number; action: string }) => {
- logger.debug({
- message: 'Android media button event received',
- context: { event },
- });
-
- // Android KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE = 85
- // Android KeyEvent.KEYCODE_HEADSETHOOK = 79
- if (event.keyCode === 85 || event.keyCode === 79) {
- if (event.action === 'ACTION_DOWN') {
- this.handleMediaButtonEvent('playPause');
- }
- }
- });
- this.eventListeners.push(mediaButtonListener);
-
- // Enable the native module to start receiving events
- MediaButtonModule.startListening?.();
-
- logger.debug({
- message: 'Android media button listeners setup via native module',
- });
- } else {
- // Fallback: Listen via DeviceEventEmitter for any media button events
- // Some audio libraries emit these events
- logger.warn({
- message: 'MediaButtonModule not available on Android - earbuds PTT may be limited',
- context: {
- suggestion: 'Install the MediaButtonModule native module for full earbuds PTT support',
- },
- });
-
- // Generic headset hook event listener (some libraries emit this)
- const headsetListener = DeviceEventEmitter.addListener('headsetButtonPressed', () => {
- this.handleMediaButtonEvent('playPause');
- });
- this.eventListeners.push(headsetListener);
- }
- }
-
- /**
- * Handle a media button event and convert it to PTT action
- */
- private handleMediaButtonEvent(type: MediaButtonEventType): void {
- if (!this.settings.enabled) {
- logger.debug({
- message: 'Media button PTT is disabled, ignoring event',
- context: { type },
- });
- return;
- }
-
- const now = Date.now();
-
- logger.info({
- message: 'Media button event received',
- context: { type, settings: this.settings },
- });
-
- // Handle double-tap detection
- if (this.settings.doubleTapAction !== 'none') {
- const timeSinceLastPress = now - this.lastPressTimestamp;
-
- if (timeSinceLastPress < this.settings.doubleTapTimeoutMs && this.pendingSingleTap) {
- // This is a double tap
- this.pendingSingleTap = false;
- if (this.doubleTapTimer) {
- clearTimeout(this.doubleTapTimer);
- this.doubleTapTimer = null;
- }
-
- logger.info({
- message: 'Double-tap detected on media button',
- });
-
- this.handleDoubleTap();
- this.lastPressTimestamp = now;
- return;
- }
-
- // Potential single tap - wait to see if it becomes a double tap
- this.lastPressTimestamp = now;
- this.pendingSingleTap = true;
-
- this.doubleTapTimer = setTimeout(() => {
- if (this.pendingSingleTap) {
- this.pendingSingleTap = false;
- this.handleSingleTap(type);
- }
- }, this.settings.doubleTapTimeoutMs);
- } else {
- // No double-tap detection, handle immediately
- this.handleSingleTap(type);
- }
- }
-
- /**
- * Handle single tap action based on PTT mode
- */
- private handleSingleTap(type: MediaButtonEventType): void {
- // Only handle play/pause type events for PTT
- if (!this.settings.usePlayPauseForPTT) {
- logger.debug({
- message: 'Play/Pause PTT disabled, ignoring',
- context: { type },
- });
- return;
- }
-
- if (type === 'playPause' || type === 'play' || type === 'pause' || type === 'togglePlayPause') {
- this.handlePTTAction();
- }
- }
-
- /**
- * Handle double tap action
- */
- private handleDoubleTap(): void {
- if (this.settings.doubleTapAction === 'toggle_mute') {
- this.handlePTTAction();
- }
- }
-
- /**
- * Execute the PTT action (toggle or push-to-talk based on mode)
- */
- private async handlePTTAction(): Promise {
- const liveKitStore = getLiveKitStore().getState();
-
- if (!liveKitStore.currentRoom) {
- logger.debug({
- message: 'No active LiveKit room, cannot handle PTT action',
- });
- return;
- }
-
- try {
- const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
- const newMicEnabled = !currentMicEnabled;
-
- await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(newMicEnabled);
-
- // Create button event for store
- const buttonEvent: AudioButtonEvent = {
- type: 'press',
- button: newMicEnabled ? 'ptt_start' : 'ptt_stop',
- timestamp: Date.now(),
- };
-
- useBluetoothAudioStore.getState().addButtonEvent(buttonEvent);
- useBluetoothAudioStore.getState().setLastButtonAction({
- action: newMicEnabled ? 'unmute' : 'mute',
- timestamp: Date.now(),
- });
-
- // Play audio feedback
- if (newMicEnabled) {
- await audioService.playStartTransmittingSound();
- } else {
- await audioService.playStopTransmittingSound();
- }
-
- logger.info({
- message: 'PTT action executed via media button (AirPods/earbuds)',
- context: {
- micEnabled: newMicEnabled,
- pttMode: this.settings.pttMode,
- },
- });
- } catch (error) {
- logger.error({
- message: 'Failed to execute PTT action via media button',
- context: { error },
- });
- }
- }
-
- /**
- * Enable microphone (for push-to-talk mode)
- */
- async enableMicrophone(): Promise {
- const liveKitStore = getLiveKitStore().getState();
-
- if (!liveKitStore.currentRoom) {
- return;
- }
-
- const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
- if (currentMicEnabled) {
- return; // Already enabled
- }
-
- try {
- await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(true);
-
- useBluetoothAudioStore.getState().addButtonEvent({
- type: 'press',
- button: 'ptt_start',
- timestamp: Date.now(),
- });
-
- useBluetoothAudioStore.getState().setLastButtonAction({
- action: 'unmute',
- timestamp: Date.now(),
- });
-
- await audioService.playStartTransmittingSound();
-
- logger.info({
- message: 'Microphone enabled via media button',
- });
- } catch (error) {
- logger.error({
- message: 'Failed to enable microphone via media button',
- context: { error },
- });
- }
- }
-
- /**
- * Disable microphone (for push-to-talk mode)
- */
- async disableMicrophone(): Promise {
- const liveKitStore = getLiveKitStore().getState();
-
- if (!liveKitStore.currentRoom) {
- return;
- }
-
- const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
- if (!currentMicEnabled) {
- return; // Already disabled
- }
-
- try {
- await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(false);
-
- useBluetoothAudioStore.getState().addButtonEvent({
- type: 'press',
- button: 'ptt_stop',
- timestamp: Date.now(),
- });
-
- useBluetoothAudioStore.getState().setLastButtonAction({
- action: 'mute',
- timestamp: Date.now(),
- });
-
- await audioService.playStopTransmittingSound();
-
- logger.info({
- message: 'Microphone disabled via media button',
- });
- } catch (error) {
- logger.error({
- message: 'Failed to disable microphone via media button',
- context: { error },
- });
- }
- }
-
- /**
- * Update PTT settings
- */
- updateSettings(settings: Partial): void {
- this.settings = { ...this.settings, ...settings };
-
- logger.info({
- message: 'Media button PTT settings updated',
- context: { settings: this.settings },
- });
- }
-
- /**
- * Get current PTT settings
- */
- getSettings(): MediaButtonPTTSettings {
- return { ...this.settings };
- }
-
- /**
- * Enable/disable media button PTT
- */
- setEnabled(enabled: boolean): void {
- this.settings.enabled = enabled;
-
- logger.info({
- message: `Media button PTT ${enabled ? 'enabled' : 'disabled'}`,
- });
- }
-
- /**
- * Check if service is initialized
- */
- isServiceInitialized(): boolean {
- return this.isInitialized;
- }
-
- /**
- * Check if native module is available
- */
- isNativeModuleAvailable(): boolean {
- return MediaButtonModule !== null && MediaButtonModule !== undefined;
- }
-
- /**
- * Cleanup and destroy the service
- */
- destroy(): void {
- logger.info({
- message: 'Destroying Media Button Service',
- });
-
- // Clear any pending timers
- if (this.doubleTapTimer) {
- clearTimeout(this.doubleTapTimer);
- this.doubleTapTimer = null;
- }
-
- // Stop native module if available
- if (MediaButtonModule?.stopListening) {
- MediaButtonModule.stopListening();
- }
-
- // Remove all event listeners
- this.eventListeners.forEach((listener) => listener.remove());
- this.eventListeners = [];
-
- this.isInitialized = false;
- this.pendingSingleTap = false;
- this.lastPressTimestamp = 0;
- }
-}
-
-export const mediaButtonService = MediaButtonService.getInstance();
diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts
index 04969860..df3d8750 100644
--- a/src/stores/app/__tests__/livekit-store.test.ts
+++ b/src/stores/app/__tests__/livekit-store.test.ts
@@ -39,29 +39,11 @@ jest.mock('../../../services/callkeep.service.ios', () => ({
getCurrentCallUUID: jest.fn(),
cleanup: jest.fn(),
setMuteStateCallback: jest.fn(),
+ setEndCallCallback: jest.fn(),
},
}));
-// Mock media button service
-jest.mock('../../../services/media-button.service', () => ({
- mediaButtonService: {
- initialize: jest.fn().mockResolvedValue(undefined),
- destroy: jest.fn(),
- updateSettings: jest.fn(),
- getSettings: jest.fn().mockReturnValue({
- enabled: true,
- pttMode: 'toggle',
- usePlayPauseForPTT: true,
- doubleTapAction: 'toggle_mute',
- doubleTapTimeoutMs: 400,
- }),
- setEnabled: jest.fn(),
- isServiceInitialized: jest.fn().mockReturnValue(true),
- isNativeModuleAvailable: jest.fn().mockReturnValue(true),
- enableMicrophone: jest.fn().mockResolvedValue(undefined),
- disableMicrophone: jest.fn().mockResolvedValue(undefined),
- },
-}));
+
import { Platform } from 'react-native';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts
index 3cc28984..d81af720 100644
--- a/src/stores/app/bluetooth-audio-store.ts
+++ b/src/stores/app/bluetooth-audio-store.ts
@@ -27,6 +27,13 @@ export interface BluetoothAudioDevice {
hasAudioCapability: boolean;
supportsMicrophoneControl: boolean;
device: Device;
+ type: 'specialized' | 'system';
+}
+
+export interface BluetoothSystemAudioDevice {
+ id: string;
+ name: string;
+ type: 'system';
}
export interface AudioButtonEvent {
@@ -57,6 +64,7 @@ interface BluetoothAudioState {
bluetoothState: State;
isScanning: boolean;
isConnecting: boolean;
+ isHeadsetButtonMonitoring: boolean;
// Devices
availableDevices: BluetoothAudioDevice[];
@@ -82,6 +90,7 @@ interface BluetoothAudioState {
setBluetoothState: (state: State) => void;
setIsScanning: (isScanning: boolean) => void;
setIsConnecting: (isConnecting: boolean) => void;
+ setIsHeadsetButtonMonitoring: (isMonitoring: boolean) => void;
// Device management
addDevice: (device: BluetoothAudioDevice) => void;
@@ -138,10 +147,12 @@ export const INITIAL_STATE: Omit<
| 'setLastButtonAction'
| 'setMediaButtonPTTSettings'
| 'setMediaButtonPTTEnabled'
+ | 'setIsHeadsetButtonMonitoring'
> = {
bluetoothState: State.Unknown,
isScanning: false,
isConnecting: false,
+ isHeadsetButtonMonitoring: false,
availableDevices: [],
connectedDevice: null,
preferredDevice: null,
@@ -167,6 +178,7 @@ export const useBluetoothAudioStore = create((set, get) =>
setBluetoothState: (state) => set({ bluetoothState: state }),
setIsScanning: (isScanning) => set({ isScanning }),
setIsConnecting: (isConnecting) => set({ isConnecting }),
+ setIsHeadsetButtonMonitoring: (isHeadsetButtonMonitoring) => set({ isHeadsetButtonMonitoring }),
// Device management actions
addDevice: (device) => {
diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts
index bc7fa557..fd24499d 100644
--- a/src/stores/app/livekit-store.ts
+++ b/src/stores/app/livekit-store.ts
@@ -11,7 +11,6 @@ import { logger } from '../../lib/logging';
import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData';
import { audioService } from '../../services/audio.service';
import { callKeepService } from '../../services/callkeep.service';
-import { mediaButtonService } from '../../services/media-button.service';
import { useBluetoothAudioStore } from './bluetooth-audio-store';
// Helper function to apply audio routing
@@ -136,9 +135,8 @@ interface LiveKitState {
canConnectApiToken: string;
canConnectToVoiceSession: boolean;
// Available rooms
+ lastLocalMuteChangeTimestamp: number;
availableRooms: DepartmentVoiceChannelResultData[];
-
- // UI state
isBottomSheetVisible: boolean;
// Actions
@@ -150,6 +148,10 @@ interface LiveKitState {
setAvailableRooms: (rooms: DepartmentVoiceChannelResultData[]) => void;
setIsBottomSheetVisible: (visible: boolean) => void;
+ // Microphone Control
+ setMicrophoneEnabled: (enabled: boolean) => Promise;
+ toggleMicrophone: () => Promise;
+
// Room operations
connectToRoom: (roomInfo: DepartmentVoiceChannelResultData, token: string) => Promise;
disconnectFromRoom: () => void;
@@ -171,6 +173,7 @@ export const useLiveKitStore = create((set, get) => ({
callerIdName: '',
canConnectApiToken: '',
canConnectToVoiceSession: false,
+ lastLocalMuteChangeTimestamp: 0,
setIsConnected: (isConnected) => set({ isConnected }),
setIsConnecting: (isConnecting) => set({ isConnecting }),
setCurrentRoom: (room) => set({ currentRoom: room }),
@@ -179,6 +182,63 @@ export const useLiveKitStore = create((set, get) => ({
setAvailableRooms: (rooms) => set({ availableRooms: rooms }),
setIsBottomSheetVisible: (visible) => set({ isBottomSheetVisible: visible }),
+ setMicrophoneEnabled: async (enabled: boolean) => {
+ const { currentRoom } = get();
+ if (!currentRoom) {
+ logger.warn({
+ message: 'Cannot set microphone - no active LiveKit room',
+ context: { enabled },
+ });
+ return;
+ }
+
+ try {
+ const currentMicEnabled = currentRoom.localParticipant.isMicrophoneEnabled;
+ // Skip if already in the desired state
+ if (enabled && currentMicEnabled) return;
+ if (!enabled && !currentMicEnabled) return;
+
+ // Update timestamp BEFORE changing state to ensure debounce logic catches it
+ set({ lastLocalMuteChangeTimestamp: Date.now() });
+
+ await currentRoom.localParticipant.setMicrophoneEnabled(enabled);
+
+ logger.info({
+ message: 'Microphone state set via store',
+ context: { enabled },
+ });
+
+ useBluetoothAudioStore.getState().setLastButtonAction({
+ action: enabled ? 'unmute' : 'mute',
+ timestamp: Date.now(),
+ });
+
+ if (enabled) {
+ await audioService.playStartTransmittingSound();
+ } else {
+ await audioService.playStopTransmittingSound();
+ }
+ } catch (error) {
+ logger.error({
+ message: 'Failed to set microphone state',
+ context: { error, enabled },
+ });
+ }
+ },
+
+ toggleMicrophone: async () => {
+ const { currentRoom } = get();
+ if (!currentRoom) {
+ logger.warn({
+ message: 'Cannot toggle microphone - no active LiveKit room'
+ });
+ return;
+ }
+
+ const currentMuteState = !currentRoom.localParticipant.isMicrophoneEnabled;
+ await get().setMicrophoneEnabled(currentMuteState);
+ },
+
requestPermissions: async () => {
try {
if (Platform.OS === 'android' || Platform.OS === 'ios') {
@@ -279,24 +339,55 @@ export const useLiveKitStore = create((set, get) => ({
await room.localParticipant.setMicrophoneEnabled(false);
await room.localParticipant.setCameraEnabled(false);
- // Initialize media button service for AirPods/earbuds PTT support
- // Initialize this EARLY to ensure listeners are registered before complex audio routing changes
- try {
- await mediaButtonService.initialize();
- // Apply stored settings from the Bluetooth audio store
- const { mediaButtonPTTSettings } = useBluetoothAudioStore.getState();
- mediaButtonService.updateSettings(mediaButtonPTTSettings);
+ // Setup CallKeep mute sync
+ callKeepService.setMuteStateCallback(async (muted) => {
logger.info({
- message: 'Media button service initialized for PTT support',
- context: { settings: mediaButtonPTTSettings },
+ message: 'Syncing mute state from CallKeep',
+ context: { muted }
});
- } catch (mediaButtonError) {
- logger.warn({
- message: 'Failed to initialize media button service - AirPods/earbuds PTT may not work',
- context: { error: mediaButtonError },
+
+ if (room.localParticipant.isMicrophoneEnabled === muted) {
+ // If CallKeep says "muted" (true), and Mic is enabled (true), we need to disable mic.
+ // If CallKeep says "unmuted" (false), and Mic is disabled (false), we need to enable mic.
+ // Wait, logic check:
+ // isMicrophoneEnabled = true means NOT MUTED.
+ // muted = true means MUTED.
+ // So if isMicrophoneEnabled (true) and muted (true) -> mismatch, we must mute.
+ // if isMicrophoneEnabled (false) and muted (false) -> mismatch, we must unmute.
+
+ // Actually effectively: setMicrophoneEnabled(!muted)
+ await room.localParticipant.setMicrophoneEnabled(!muted);
+
+ // Play sound
+ if (!muted) {
+ await audioService.playStartTransmittingSound();
+ } else {
+ await audioService.playStopTransmittingSound();
+ }
+ }
+ });
+
+ // Setup CallKeep End Call sync
+ callKeepService.setEndCallCallback(() => {
+ logger.info({
+ message: 'CallKeep end call event received, disconnecting room',
});
- // Don't fail the connection if media button service fails
- }
+ get().disconnectFromRoom();
+ });
+
+ // Also ensure reverse sync: When app mutes, tell CallKeep?
+ // CallKeep tracks its own state, usually triggered by UI or simple interactions.
+ // If we mute from within the app (e.g. on screen button), we might want to tell CallKeep we are muted.
+ // However, react-native-callkeep doesn't easily expose "setMuted" for the system call without ending logic or being complex?
+ // Actually RNCallKeep.setMutedCall(uuid, muted) exists.
+
+ const onLocalTrackMuted = () => {
+ // Update CallKeep state if needed?
+ // For now, let's just trust CallKeep's own state management or system UI.
+ };
+
+ // We attach these listeners to the local participant if needed for other UI sync
+
// Setup audio routing based on selected devices
// This may change audio modes/focus, so it comes after media button init
@@ -400,18 +491,11 @@ export const useLiveKitStore = create((set, get) => ({
});
}
- // Cleanup media button service
- try {
- mediaButtonService.destroy();
- logger.debug({
- message: 'Media button service cleaned up',
- });
- } catch (mediaButtonError) {
- logger.warn({
- message: 'Failed to cleanup media button service',
- context: { error: mediaButtonError },
- });
- }
+ // Cleanup CallKeep Mute Callback
+ callKeepService.setMuteStateCallback(null);
+ callKeepService.setEndCallCallback(null);
+
+
set({
currentRoom: null,
diff --git a/src/types/ptt.ts b/src/types/ptt.ts
index 7b0de5e4..bf01ed5e 100644
--- a/src/types/ptt.ts
+++ b/src/types/ptt.ts
@@ -1,6 +1,6 @@
/**
* PTT (Push-to-Talk) types and settings for media button functionality.
- * Used by bluetooth-audio-store and media-button.service.
+ * Used by bluetooth-audio-store and headset-button.service.
*/
/**