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. */ /**