diff --git a/.DS_Store b/.DS_Store index 8d05e935..5df6e1de 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/app.config.ts b/app.config.ts index 7e2b8fc9..0ef1f958 100644 --- a/app.config.ts +++ b/app.config.ts @@ -80,6 +80,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'android.permission.FOREGROUND_SERVICE_MICROPHONE', 'android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE', 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK', + 'android.permission.READ_PHONE_STATE', + 'android.permission.MANAGE_OWN_CALLS', ], }, web: { @@ -208,6 +210,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ './plugins/withForegroundNotifications.js', './plugins/withNotificationSounds.js', './plugins/withMediaButtonModule.js', + './plugins/withInCallAudioModule.js', ['app-icon-badge', appIconBadgeConfig], ], extra: { diff --git a/expo-env.d.ts b/expo-env.d.ts index bf3c1693..5411fdde 100644 --- a/expo-env.d.ts +++ b/expo-env.d.ts @@ -1,3 +1,3 @@ /// -// NOTE: This file should not be edited and should be in your git ignore +// NOTE: This file should not be edited and should be in your git ignore \ No newline at end of file diff --git a/package.json b/package.json index 4912cc21..ddc419a9 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "axios": "~1.12.0", "babel-plugin-module-resolver": "^5.0.2", "buffer": "^6.0.3", - "countly-sdk-react-native-bridge": "^25.4.0", + "countly-sdk-react-native-bridge": "25.4.1", "date-fns": "^4.1.0", "expo": "~53.0.23", "expo-application": "~6.1.5", @@ -148,6 +148,7 @@ "mapbox-gl": "3.18.1", "moti": "~0.29.0", "nativewind": "~4.1.21", + "promise": "8.3.0", "react": "19.0.0", "react-dom": "19.0.0", "react-error-boundary": "~4.0.13", @@ -258,6 +259,7 @@ "initVersion": "7.0.4" }, "resolutions": { - "form-data": "4.0.4" + "form-data": "4.0.4", + "promise": "8.3.0" } } diff --git a/plugins/withInCallAudioModule.js b/plugins/withInCallAudioModule.js new file mode 100644 index 00000000..5add14d4 --- /dev/null +++ b/plugins/withInCallAudioModule.js @@ -0,0 +1,261 @@ +const { withDangerousMod, withMainApplication } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * Android InCallAudioModule.kt content + * Uses SoundPool to play sounds on the VOICE_COMMUNICATION stream. + */ +const ANDROID_MODULE = `package {{PACKAGE_NAME}} + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.os.Build +import android.util.Log +import com.facebook.react.bridge.* + +class InCallAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + companion object { + private const val TAG = "InCallAudioModule" + } + + private var soundPool: SoundPool? = null + private val soundMap = HashMap() + private val loadedSounds = HashSet() + private var isInitialized = false + + override fun getName(): String { + return "InCallAudioModule" + } + + @ReactMethod + fun initializeAudio() { + if (isInitialized) return + + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + + soundPool = SoundPool.Builder() + .setMaxStreams(1) + .setAudioAttributes(audioAttributes) + .build() + + soundPool?.setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) { + loadedSounds.add(sampleId) + Log.d(TAG, "Sound loaded successfully: $sampleId") + } else { + Log.e(TAG, "Failed to load sound $sampleId, status: $status") + } + } + + isInitialized = true + Log.d(TAG, "InCallAudioModule initialized with USAGE_VOICE_COMMUNICATION") + } + + @ReactMethod + fun loadSound(name: String, resourceName: String) { + if (!isInitialized) initializeAudio() + + val context = reactApplicationContext + var resId = context.resources.getIdentifier(resourceName, "raw", context.packageName) + + // Fallback: Try identifying without package name if first attempt fails (though context.packageName is usually correct) + if (resId == 0) { + Log.w(TAG, "Resource $resourceName not found in \${context.packageName}, trying simplified lookup") + // Reflection-based lookup if needed, but getIdentifier is standard. + } + + if (resId != 0) { + soundPool?.let { pool -> + val soundId = pool.load(context, resId, 1) + soundMap[name] = soundId + Log.d(TAG, "Loading sound: $name from resource: $resourceName (id: $soundId, resId: $resId)") + } + } else { + Log.e(TAG, "Resource not found: $resourceName in package \${context.packageName}") + } + } + + @ReactMethod + fun playSound(name: String) { + val soundId = soundMap[name] + if (soundId != null) { + if (loadedSounds.contains(soundId)) { + val streamId = soundPool?.play(soundId, 0.5f, 0.5f, 1, 0, 1.0f) + if (streamId == 0) { + Log.e(TAG, "Failed to play sound: $name (id: $soundId). StreamId is 0. Check Volume/Focus.") + } else { + Log.d(TAG, "Playing sound: $name (id: $soundId, stream: $streamId)") + } + } else { + Log.w(TAG, "Sound $name (id: $soundId) is not ready yet. Ignoring play request.") + } + } else { + Log.w(TAG, "Sound not found in map: $name") + } + } + + @ReactMethod + fun cleanup() { + soundPool?.release() + soundPool = null + soundMap.clear() + loadedSounds.clear() + isInitialized = false + Log.d(TAG, "InCallAudioModule cleaned up") + } +} +`; + +/** + * Android InCallAudioPackage.kt content + */ +const ANDROID_PACKAGE = `package {{PACKAGE_NAME}} + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class InCallAudioPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(InCallAudioModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List>> { + return emptyList() + } +} +`; + +/** + * Helper to resolve package name + */ +function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') { + const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/; + + const groovyPath = path.join(projectRoot, 'android', 'app', 'build.gradle'); + if (fs.existsSync(groovyPath)) { + const content = fs.readFileSync(groovyPath, 'utf-8'); + const match = content.match(namespaceRegex); + if (match) return match[1]; + } + + const ktsPath = path.join(projectRoot, 'android', 'app', 'build.gradle.kts'); + if (fs.existsSync(ktsPath)) { + const content = fs.readFileSync(ktsPath, 'utf-8'); + const match = content.match(namespaceRegex); + if (match) return match[1]; + } + + return fallback; +} + +const withInCallAudioModule = (config) => { + // 1. Copy Assets to Android res/raw + config = withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const resRawPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res', 'raw'); + + if (!fs.existsSync(resRawPath)) { + fs.mkdirSync(resRawPath, { recursive: true }); + } + + const assets = ['software_interface_start.mp3', 'software_interface_back.mp3', 'positive_interface_beep.mp3', 'space_notification1.mp3', 'space_notification2.mp3']; + + const sourceBase = path.join(projectRoot, 'assets', 'audio', 'ui'); + + assets.forEach((filename) => { + const sourcePath = path.join(sourceBase, filename); + const destPath = path.join(resRawPath, filename); + + if (fs.existsSync(sourcePath)) { + fs.copyFileSync(sourcePath, destPath); + console.log(`[withInCallAudioModule] Copied ${filename} to res/raw/${filename}`); + } else { + console.warn(`[withInCallAudioModule] Source audio file not found: ${sourcePath}`); + } + }); + + return config; + }, + ]); + + // 2. Add Native Module Code + config = withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const packageName = resolveBasePackageName(projectRoot); + const packagePath = packageName.replace(/\./g, '/'); + const androidSrcPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', packagePath); + + if (!fs.existsSync(androidSrcPath)) { + fs.mkdirSync(androidSrcPath, { recursive: true }); + } + + // InCallAudioModule.kt + const modulePath = path.join(androidSrcPath, 'InCallAudioModule.kt'); + const moduleContent = ANDROID_MODULE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(modulePath, moduleContent); + console.log('[withInCallAudioModule] Created InCallAudioModule.kt'); + + // InCallAudioPackage.kt + const packageFilePath = path.join(androidSrcPath, 'InCallAudioPackage.kt'); + const packageContent = ANDROID_PACKAGE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(packageFilePath, packageContent); + console.log('[withInCallAudioModule] Created InCallAudioPackage.kt'); + + return config; + }, + ]); + + // 3. Register Package in MainApplication.kt + config = withMainApplication(config, (config) => { + const mainApplication = config.modResults; + const projectRoot = config.modRequest.projectRoot; + + if (!mainApplication.contents.includes('InCallAudioPackage')) { + const basePackageName = resolveBasePackageName(projectRoot); + const importStatement = `import ${basePackageName}.InCallAudioPackage`; + + if (!mainApplication.contents.includes(importStatement)) { + mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`); + } + + const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/; + const packagesMatch = mainApplication.contents.match(packagesPattern); + + if (packagesMatch) { + // Using the simplest replacement that ensures toMutableList() + const replacement = `val packages = PackageList(this).packages.toMutableList()\n packages.add(InCallAudioPackage())`; + + // Avoid double adding if MediaButtonPackage logic already changed it to mutable + if (mainApplication.contents.includes('packages.add(MediaButtonPackage()')) { + // Add ours after MediaButtonPackage + mainApplication.contents = mainApplication.contents.replace('packages.add(MediaButtonPackage())', 'packages.add(MediaButtonPackage())\n packages.add(InCallAudioPackage())'); + } else { + // Standard replacement + mainApplication.contents = mainApplication.contents.replace(packagesPattern, replacement); + } + console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt'); + } + } + + return config; + }); + + return config; +}; + +module.exports = withInCallAudioModule; diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index edb87d60..9f5ec59e 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -182,3 +182,5 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde ); }; + +export default LoginForm; diff --git a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx index 65695a26..2073fc38 100644 --- a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx +++ b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx @@ -85,6 +85,19 @@ jest.mock('i18next', () => ({ }, })); +// Mock Actionsheet +jest.mock('../../ui/actionsheet', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + Actionsheet: ({ children, isOpen }: any) => isOpen ? {children} : null, + ActionsheetBackdrop: () => null, + ActionsheetContent: ({ children }: any) => {children}, + ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + ActionsheetDragIndicator: () => null, + }; +}); + // Import after mocks to avoid the React Native CSS Interop issue import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; @@ -451,6 +464,63 @@ describe('LiveKitBottomSheet', () => { await audioService.playDisconnectionSound(); expect(audioService.playDisconnectionSound).toHaveBeenCalledTimes(1); }); + + it('should return to room select when back is pressed from audio settings if entered from room select', () => { + const { fireEvent } = require('@testing-library/react-native'); + + mockUseLiveKitStore.mockReturnValue({ + ...defaultLiveKitState, + isBottomSheetVisible: true, + availableRooms: mockAvailableRooms, + }); + + const component = render(); + + // Navigate to audio settings + const settingsButton = component.getByTestId('header-audio-settings-button'); + fireEvent.press(settingsButton); + + // Verify we are in audio settings + expect(component.getByTestId('audio-settings-view')).toBeTruthy(); + + // Press back + const backButton = component.getByTestId('back-button'); + fireEvent.press(backButton); + + // Verify we are back in room select (by checking for join buttons) + expect(component.getByTestId('room-list')).toBeTruthy(); + }); + + it('should return to connected view when back is pressed from audio settings if entered from connected view', async () => { + const { fireEvent } = require('@testing-library/react-native'); + + mockUseLiveKitStore.mockReturnValue({ + ...defaultLiveKitState, + isBottomSheetVisible: true, + isConnected: true, + currentRoomInfo: mockCurrentRoomInfo, + currentRoom: mockRoom, + }); + + const component = render(); + + // Verify we are in connected view + await expect(component.findByTestId('connected-view')).resolves.toBeTruthy(); + + // Navigate to audio settings + const settingsButton = component.getByTestId('audio-settings-button'); + fireEvent.press(settingsButton); + + // Verify we are in audio settings + expect(component.getByTestId('audio-settings-view')).toBeTruthy(); + + // Press back + const backButton = component.getByTestId('back-button'); + fireEvent.press(backButton); + + // Verify we are back in connected view + expect(component.getByTestId('connected-view')).toBeTruthy(); + }); }); describe('Analytics', () => { diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx index 3f832ebd..67852c58 100644 --- a/src/components/livekit/livekit-bottom-sheet.tsx +++ b/src/components/livekit/livekit-bottom-sheet.tsx @@ -11,7 +11,7 @@ import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { Card } from '../../components/ui/card'; import { Text } from '../../components/ui/text'; -import { useLiveKitStore } from '../../stores/app/livekit-store'; +import { applyAudioRouting, useLiveKitStore } from '../../stores/app/livekit-store'; import { AudioDeviceSelection } from '../settings/audio-device-selection'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; import { HStack } from '../ui/hstack'; @@ -32,6 +32,7 @@ export const LiveKitBottomSheet = () => { const { trackEvent } = useAnalytics(); const [currentView, setCurrentView] = useState(BottomSheetView.ROOM_SELECT); + const [previousView, setPreviousView] = useState(null); const [isMuted, setIsMuted] = useState(true); // Default to muted const [permissionsRequested, setPermissionsRequested] = useState(false); @@ -146,6 +147,34 @@ export const LiveKitBottomSheet = () => { } }, [isConnected, currentRoomInfo]); + // Audio Routing Logic + useEffect(() => { + const updateAudioRouting = async () => { + if (!selectedAudioDevices.speaker) return; + + try { + const speaker = selectedAudioDevices.speaker; + console.log('Updating audio routing for:', speaker.type); + + let targetType: 'bluetooth' | 'speaker' | 'earpiece' | 'default' = 'default'; + + if (speaker.type === 'speaker') { + targetType = 'speaker'; + } else if (speaker.type === 'bluetooth') { + targetType = 'bluetooth'; + } else { + targetType = 'earpiece'; + } + + await applyAudioRouting(targetType); + } catch (error) { + console.error('Failed to update audio routing:', error); + } + }; + + updateAudioRouting(); + }, [selectedAudioDevices.speaker]); + const handleRoomSelect = useCallback( (room: DepartmentVoiceChannelResultData) => { connectToRoom(room, room.Token); @@ -181,12 +210,18 @@ export const LiveKitBottomSheet = () => { }, [disconnectFromRoom]); const handleShowAudioSettings = useCallback(() => { + setPreviousView(currentView); setCurrentView(BottomSheetView.AUDIO_SETTINGS); - }, []); + }, [currentView]); const handleBackFromAudioSettings = useCallback(() => { - setCurrentView(BottomSheetView.CONNECTED); - }, []); + if (previousView) { + setCurrentView(previousView); + setPreviousView(null); + } else { + setCurrentView(isConnected && currentRoomInfo ? BottomSheetView.CONNECTED : BottomSheetView.ROOM_SELECT); + } + }, [previousView, isConnected, currentRoomInfo]); const renderRoomSelect = () => ( @@ -303,7 +338,7 @@ export const LiveKitBottomSheet = () => { {t('livekit.title')} - {currentView === BottomSheetView.CONNECTED && ( + {currentView !== BottomSheetView.AUDIO_SETTINGS && ( @@ -323,7 +358,7 @@ const styles = StyleSheet.create({ content: { flex: 1, width: '100%', - paddingHorizontal: 16, + paddingHorizontal: 8, }, roomList: { flex: 1, @@ -334,6 +369,7 @@ const styles = StyleSheet.create({ controls: { flexDirection: 'row', justifyContent: 'space-around', + width: '100%', marginTop: 16, }, controlButton: { diff --git a/src/components/settings/__tests__/audio-device-selection.test.tsx b/src/components/settings/__tests__/audio-device-selection.test.tsx index 2b395a01..8490b725 100644 --- a/src/components/settings/__tests__/audio-device-selection.test.tsx +++ b/src/components/settings/__tests__/audio-device-selection.test.tsx @@ -235,7 +235,7 @@ describe('AudioDeviceSelection', () => { expect(screen.getAllByText('Available BT').length).toBeGreaterThan(0); expect(screen.queryByText('Unavailable BT')).toBeNull(); - expect(screen.getAllByText('Wired Device').length).toBeGreaterThan(0); + expect(screen.queryByText('Wired Device')).toBeNull(); }); it('filters out unavailable devices for speakers', () => { @@ -247,9 +247,8 @@ describe('AudioDeviceSelection', () => { render(); expect(screen.getAllByText('Available Device').length).toBeGreaterThan(0); - // Note: The component actually shows ALL devices in microphone section unless they are unavailable bluetooth - // So the unavailable speaker will show in microphone section but not speaker section - expect(screen.getAllByText('Unavailable Device').length).toBeGreaterThan(0); // Shows in microphone section + // Note: We now filter out unavailable devices from BOTH sections. + expect(screen.queryByText('Unavailable Device')).toBeNull(); }); }); @@ -275,9 +274,8 @@ describe('AudioDeviceSelection', () => { render(); - // Device should appear but with fallback label - expect(screen.getAllByText('Unknown Device').length).toBeGreaterThan(0); - expect(screen.getAllByText('Unknown Device').length).toBeGreaterThan(0); + // Device should be filtered out as type is unknown + expect(screen.queryByText('Unknown Device')).toBeNull(); }); }); }); diff --git a/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx b/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx index a32fe338..058042e9 100644 --- a/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx @@ -67,14 +67,22 @@ jest.mock('@/lib/hooks/use-preferred-bluetooth-device', () => ({ }), })); -jest.mock('@/stores/app/bluetooth-audio-store', () => ({ - State: { - PoweredOn: 'poweredOn', - PoweredOff: 'poweredOff', - Unauthorized: 'unauthorized', - }, - useBluetoothAudioStore: jest.fn(), -})); +jest.mock('@/stores/app/bluetooth-audio-store', () => { + const mockStore = jest.fn(); + // @ts-ignore + mockStore.getState = jest.fn(() => ({ + setIsConnecting: jest.fn(), + })); + + return { + State: { + PoweredOn: 'poweredOn', + PoweredOff: 'poweredOff', + Unauthorized: 'unauthorized', + }, + useBluetoothAudioStore: mockStore, + }; +}); jest.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -104,7 +112,21 @@ jest.mock('lucide-react-native', () => ({ WifiIcon: 'WifiIcon', })); +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + // Mock gluestack UI components + +jest.mock('react-native-flash-message', () => ({ + showMessage: jest.fn(), +})); + jest.mock('@/components/ui/bottom-sheet', () => ({ CustomBottomSheet: ({ children, isOpen }: any) => isOpen ? children : null, })); @@ -203,6 +225,9 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset implementations to successful defaults + (bluetoothAudioService.connectToDevice as jest.Mock).mockResolvedValue(undefined); + mockUseBluetoothAudioStore.mockReturnValue({ availableDevices: [ { @@ -310,7 +335,7 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { jest.clearAllMocks(); }); - it('clears preferred device and disconnects before connecting to new device', async () => { + it('connects to new device successfully', async () => { const mockConnectedDevice = { id: 'current-device', name: 'Current Device', @@ -346,13 +371,8 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { fireEvent.press(deviceItem); await waitFor(() => { - // Should first clear the preferred device - expect(mockSetPreferredDevice).toHaveBeenCalledWith(null); - }); - - await waitFor(() => { - // Should disconnect from current device - expect(bluetoothAudioService.disconnectDevice).toHaveBeenCalled(); + // Should connect to the new device + expect(bluetoothAudioService.connectToDevice).toHaveBeenCalledWith('test-device-1'); }); await waitFor(() => { @@ -363,71 +383,11 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { }); }); - await waitFor(() => { - // Should connect to the new device - expect(bluetoothAudioService.connectToDevice).toHaveBeenCalledWith('test-device-1'); - }); - // Should close the modal expect(mockProps.onClose).toHaveBeenCalled(); }); - it('handles disconnect failure gracefully and continues with new connection', async () => { - const mockConnectedDevice = { - id: 'current-device', - name: 'Current Device', - rssi: -40, - isConnected: true, - hasAudioCapability: true, - supportsMicrophoneControl: true, - device: {} as any, - }; - - // Make disconnect fail - (bluetoothAudioService.disconnectDevice as jest.Mock).mockRejectedValue(new Error('Disconnect failed')); - - mockUseBluetoothAudioStore.mockReturnValue({ - availableDevices: [ - { - id: 'test-device-1', - name: 'Test Headset', - rssi: -50, - isConnected: false, - hasAudioCapability: true, - supportsMicrophoneControl: true, - device: {} as any, - }, - ], - isScanning: false, - bluetoothState: State.PoweredOn, - connectedDevice: mockConnectedDevice, - connectionError: null, - } as any); - - render(); - // Find and tap on the test device - const deviceItem = screen.getByText('Test Headset'); - fireEvent.press(deviceItem); - - await waitFor(() => { - // Should still attempt disconnect - expect(bluetoothAudioService.disconnectDevice).toHaveBeenCalled(); - }); - - await waitFor(() => { - // Should still continue with setting preferred device - expect(mockSetPreferredDevice).toHaveBeenCalledWith({ - id: 'test-device-1', - name: 'Test Headset', - }); - }); - - await waitFor(() => { - // Should still attempt to connect to new device - expect(bluetoothAudioService.connectToDevice).toHaveBeenCalledWith('test-device-1'); - }); - }); it('handles connection failure gracefully', async () => { // Make connect fail @@ -458,11 +418,8 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { fireEvent.press(deviceItem); await waitFor(() => { - // Should still set preferred device - expect(mockSetPreferredDevice).toHaveBeenCalledWith({ - id: 'test-device-1', - name: 'Test Headset', - }); + // Should NOT set preferred device if connection fails + expect(mockSetPreferredDevice).not.toHaveBeenCalled(); }); await waitFor(() => { @@ -470,8 +427,8 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { expect(bluetoothAudioService.connectToDevice).toHaveBeenCalledWith('test-device-1'); }); - // Should still close the modal even if connection fails - expect(mockProps.onClose).toHaveBeenCalled(); + // Should keep the modal open + expect(mockProps.onClose).not.toHaveBeenCalled(); }); it('processes device selection when no device is currently connected', async () => { @@ -500,13 +457,10 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { fireEvent.press(deviceItem); await waitFor(() => { - // Should clear preferred device first - expect(mockSetPreferredDevice).toHaveBeenCalledWith(null); + // Should connect to new device + expect(bluetoothAudioService.connectToDevice).toHaveBeenCalledWith('test-device-1'); }); - // Should not call disconnect since no device is connected - expect(bluetoothAudioService.disconnectDevice).not.toHaveBeenCalled(); - await waitFor(() => { // Should set new preferred device expect(mockSetPreferredDevice).toHaveBeenCalledWith({ @@ -514,11 +468,6 @@ describe('BluetoothDeviceSelectionBottomSheet', () => { name: 'Test Headset', }); }); - - await waitFor(() => { - // Should connect to new device - expect(bluetoothAudioService.connectToDevice).toHaveBeenCalledWith('test-device-1'); - }); }); }); }); diff --git a/src/components/settings/audio-device-selection.tsx b/src/components/settings/audio-device-selection.tsx index f738f042..065c4035 100644 --- a/src/components/settings/audio-device-selection.tsx +++ b/src/components/settings/audio-device-selection.tsx @@ -70,9 +70,9 @@ export const AudioDeviceSelection: React.FC = ({ show ); }; - const availableMicrophones = availableAudioDevices.filter((device) => (device.type === 'bluetooth' ? device.isAvailable : true)); + const availableMicrophones = availableAudioDevices.filter((device) => device.isAvailable && (device.type === 'default' || device.type === 'microphone' || device.type === 'bluetooth' || device.type === 'wired')); - const availableSpeakers = availableAudioDevices.filter((device) => device.isAvailable); + const availableSpeakers = availableAudioDevices.filter((device) => device.isAvailable && (device.type === 'default' || device.type === 'speaker' || device.type === 'bluetooth' || device.type === 'wired')); return ( diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx index 6c3434d5..daf8441b 100644 --- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx +++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx @@ -2,6 +2,7 @@ import { BluetoothIcon, RefreshCwIcon, WifiIcon } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, useWindowDimensions } from 'react-native'; +import { showMessage } from 'react-native-flash-message'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; @@ -31,6 +32,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo const { preferredDevice, setPreferredDevice } = usePreferredBluetoothDevice(); const { availableDevices, isScanning, bluetoothState, connectedDevice, connectionError } = useBluetoothAudioStore(); const [hasScanned, setHasScanned] = useState(false); + const [connectingDeviceId, setConnectingDeviceId] = useState(null); // Start scanning when sheet opens useEffect(() => { @@ -58,74 +60,80 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo const handleDeviceSelect = React.useCallback( async (device: BluetoothAudioDevice) => { try { - // First, clear any existing preferred device - await setPreferredDevice(null); + // Disconnect from any currently connected device first? + // The service connectToDevice usually handles this or we do it here. + // Previous code did disconnect manually. + /* + if (connectedDevice) { + ... disconnect ... + } + */ + // User wants "When attempting to connect... display loading". + // User wants "If error... don't save device". + + // 1. Set connecting state + // We can resolve this by using local state for the specific device being connected to, + // or rely on store's global isConnecting. + // Let's use the store's setIsConnecting to be consistent if the UI uses it. + useBluetoothAudioStore.getState().setIsConnecting(true); + setConnectingDeviceId(device.id); + + // 2. Clear existing preferred temporarily? Or just wait? + // User said "don't save the save device, only do that when connection is successful". + // So we shouldn't touch preferredDevice yet. logger.info({ - message: 'Clearing existing preferred Bluetooth device before setting new one', - context: { newDeviceId: device.id, newDeviceName: device.name }, + message: 'Attempting to connect to Bluetooth device', + context: { deviceId: device.id, deviceName: device.name }, }); - // Disconnect from any currently connected device - if (connectedDevice) { - try { - await bluetoothAudioService.disconnectDevice(); - logger.info({ - message: 'Disconnected from previous Bluetooth device', - context: { previousDeviceId: connectedDevice.id }, - }); - } catch (disconnectError) { - logger.warn({ - message: 'Failed to disconnect from previous device', - context: { previousDeviceId: connectedDevice.id, error: disconnectError }, - }); - // Continue with connection to new device even if disconnect fails - } - } + // 3. Connect + await bluetoothAudioService.connectToDevice(device.id); - // Set the new preferred device + // 4. Success handling + logger.info({ + message: 'Successfully connected to new Bluetooth device', + context: { deviceId: device.id }, + }); + + // Set as preferred only on success const selectedDevice = { id: device.id, name: device.name || t('bluetooth.unknown_device'), }; - await setPreferredDevice(selectedDevice); - logger.info({ - message: 'New preferred Bluetooth device selected', - context: { deviceId: device.id, deviceName: device.name }, - }); - - // Connect to the new device - try { - await bluetoothAudioService.connectToDevice(device.id); - logger.info({ - message: 'Successfully connected to new Bluetooth device', - context: { deviceId: device.id }, - }); - } catch (connectionError) { - logger.warn({ - message: 'Failed to connect to selected device immediately', - context: { deviceId: device.id, error: connectionError }, - }); - // Don't show error to user as they may just want to set preference - } + // Sound is handled by service or we can ensure it here: + // await audioService.playConnectedToAudioRoomSound(); // If service doesn't do it. + // Checking previous logs, service seems to play "connectedDevice". onClose(); } catch (error) { - logger.error({ - message: 'Failed to set preferred Bluetooth device', - context: { error }, + logger.warn({ + message: 'Failed to connect to device', + context: { error, deviceId: device.id }, }); - Alert.alert(t('bluetooth.selection_error_title'), t('bluetooth.selection_error_message'), [{ text: t('common.ok') }]); + // 5. Error handling + const errorMessage = error instanceof Error ? error.message : String(error); + showMessage({ + message: t('bluetooth.connection_error_title') || 'Connection Failed', + description: errorMessage === 'Device disconnected' ? t('bluetooth.device_disconnected') : errorMessage || t('bluetooth.connection_error_message') || 'Could not connect to device', + type: 'danger', + duration: 4000, + }); + // Keep sheet open (don't call onClose) + } finally { + useBluetoothAudioStore.getState().setIsConnecting(false); + setConnectingDeviceId(null); } }, - [setPreferredDevice, onClose, t, connectedDevice] + [setPreferredDevice, onClose, t] ); const handleClearSelection = React.useCallback(async () => { try { + await bluetoothAudioService.reset(); await setPreferredDevice(null); onClose(); } catch (error) { @@ -151,11 +159,13 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo ({ item }: { item: BluetoothAudioDevice }) => { const isSelected = preferredDevice?.id === item.id; const isConnected = connectedDevice?.id === item.id; + const isConnectingToThisDevice = connectingDeviceId === item.id; return ( handleDeviceSelect(item)} - className={`mb-2 rounded-lg border p-4 ${isSelected ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} + disabled={!!connectingDeviceId} + className={`mb-2 rounded-lg border p-4 ${isSelected ? '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' : ''}`} > @@ -170,17 +180,19 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo {item.hasAudioCapability && {t('bluetooth.audio_capable')}} - {isSelected && ( + {isConnectingToThisDevice ? ( + + ) : isSelected ? ( {t('bluetooth.selected')} {isConnected && {t('bluetooth.connected')}} - )} + ) : null} ); }, - [preferredDevice, connectedDevice, handleDeviceSelect, t] + [preferredDevice, connectedDevice, handleDeviceSelect, t, connectingDeviceId] ); const renderEmptyState = useCallback(() => { @@ -235,7 +247,17 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo {/* Device List */} - item.id} ListEmptyComponent={renderEmptyState} className="flex-1" showsVerticalScrollIndicator={false} /> + + item.id} + ListEmptyComponent={renderEmptyState} + showsVerticalScrollIndicator={false} + estimatedItemSize={60} + extraData={connectingDeviceId} + /> + {/* Bluetooth State Info */} {bluetoothState !== State.PoweredOn && ( diff --git a/src/components/ui/actionsheet/index.tsx b/src/components/ui/actionsheet/index.tsx index 0c2a6507..5a5302f3 100644 --- a/src/components/ui/actionsheet/index.tsx +++ b/src/components/ui/actionsheet/index.tsx @@ -389,15 +389,11 @@ const ActionsheetVirtualizedList = React.forwardRef, IActionsheetFlatListProps>(({ className, ...props }, ref) => { +const ActionsheetFlatList = React.forwardRef, IActionsheetFlatListProps>(({ className, style, ...props }, ref) => { return ( - + + + ); }); diff --git a/src/components/ui/select/select-actionsheet.tsx b/src/components/ui/select/select-actionsheet.tsx index a0bd0f68..bb630cd3 100644 --- a/src/components/ui/select/select-actionsheet.tsx +++ b/src/components/ui/select/select-actionsheet.tsx @@ -389,15 +389,11 @@ const ActionsheetVirtualizedList = React.forwardRef, IActionsheetFlatListProps>(function ActionsheetFlatList({ className, ...props }, ref) { +const ActionsheetFlatList = React.forwardRef, IActionsheetFlatListProps>(function ActionsheetFlatList({ className, style, ...props }, ref) { return ( - + + + ); }); diff --git a/src/services/__tests__/app-initialization.service.test.ts b/src/services/__tests__/app-initialization.service.test.ts index 4208afcd..375b9331 100644 --- a/src/services/__tests__/app-initialization.service.test.ts +++ b/src/services/__tests__/app-initialization.service.test.ts @@ -82,16 +82,22 @@ describe('AppInitializationService', () => { expect(appInitializationService.isAppInitialized()).toBe(true); }); - it('should skip CallKeep initialization on Android', async () => { + it('should initialize CallKeep on Android', async () => { (Platform as any).OS = 'android'; await appInitializationService.initialize(); - expect(mockCallKeepService.setup).not.toHaveBeenCalled(); + expect(mockCallKeepService.setup).toHaveBeenCalledWith({ + appName: 'Resgrid Unit', + maximumCallGroups: 1, + maximumCallsPerCallGroup: 1, + includesCallsInRecents: false, + supportsVideo: false, + }); expect(mockPushNotificationService.initialize).toHaveBeenCalled(); - expect(mockLogger.debug).toHaveBeenCalledWith({ - message: 'CallKeep initialization skipped - not iOS platform', - context: { platform: 'android' }, + + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'CallKeep initialized successfully', }); expect(mockLogger.info).toHaveBeenCalledWith({ message: 'App initialization completed successfully', diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 02adf71c..304d0d5b 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -46,6 +46,14 @@ jest.mock('@/stores/app/audio-stream-store', () => ({ })); jest.mock('@/stores/app/bluetooth-audio-store', () => ({ + INITIAL_STATE: { + connectedDevice: null, + isScanning: false, + isConnecting: false, + availableDevices: [], + connectionError: null, + isAudioRoutingActive: false, + }, useBluetoothAudioStore: { setState: jest.fn(), getState: jest.fn(() => ({})), @@ -422,7 +430,7 @@ describe('app-reset.service', () => { await resetAllStores(); expect(mockLiveKitDisconnect).toHaveBeenCalled(); - expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE, true); + expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE); }); it('should not disconnect from LiveKit room if not connected', async () => { @@ -437,7 +445,7 @@ describe('app-reset.service', () => { await resetAllStores(); expect(localMockDisconnect).not.toHaveBeenCalled(); - expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE, true); + expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE); }); }); diff --git a/src/services/__tests__/audio.service.test.ts b/src/services/__tests__/audio.service.test.ts index 3f57d7ac..e44a32db 100644 --- a/src/services/__tests__/audio.service.test.ts +++ b/src/services/__tests__/audio.service.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; const mockSound = { setPositionAsync: jest.fn(), playAsync: jest.fn(), + replayAsync: jest.fn(), unloadAsync: jest.fn(), getStatusAsync: jest.fn(), } as any; @@ -13,6 +14,8 @@ const mockAsset = { uri: 'mock://uri', } as any; +import { audioService } from '../audio.service'; + // Mock expo-modules-core first to prevent NativeUnimoduleProxy errors jest.mock('expo-modules-core', () => ({ NativeModulesProxy: {}, @@ -78,12 +81,10 @@ const mockAudioSetAudioModeAsync = Audio.setAudioModeAsync as jest.MockedFunctio const mockSoundCreateAsync = Audio.Sound.createAsync as jest.MockedFunction; describe('AudioService', () => { - let audioService: any; - beforeEach(async () => { jest.clearAllMocks(); - // Set up mocks with proper return values BEFORE importing the service + // Set up mocks with proper return values mockAssetLoadAsync.mockResolvedValue([] as any); mockAssetFromModule.mockReturnValue(mockAsset); (mockAudioSetAudioModeAsync as jest.MockedFunction).mockResolvedValue(undefined); @@ -91,18 +92,25 @@ describe('AudioService', () => { mockAsset.downloadAsync.mockResolvedValue(undefined); mockSound.setPositionAsync.mockResolvedValue({} as any); mockSound.playAsync.mockResolvedValue({} as any); + mockSound.replayAsync.mockResolvedValue({} as any); mockSound.unloadAsync.mockResolvedValue({} as any); mockSound.getStatusAsync.mockResolvedValue({ isLoaded: true } as any); // Clear the module cache to ensure fresh imports - delete require.cache[require.resolve('../audio.service')]; + // delete require.cache[require.resolve('../audio.service')]; // Import the service after setting up mocks - const AudioServiceModule = require('../audio.service'); - audioService = AudioServiceModule.audioService; + // const AudioServiceModule = require('../audio.service'); + // audioService = AudioServiceModule.audioService; // Reset the initialization flag and manually trigger initialization to ensure it runs with our mocks + // Reset the initialization flag and manually trigger initialization (audioService as any).isInitialized = false; + (audioService as any).startTransmittingSound = null; + (audioService as any).stopTransmittingSound = null; + (audioService as any).connectedDeviceSound = null; + (audioService as any).connectToAudioRoomSound = null; + (audioService as any).disconnectedFromAudioRoomSound = null; await audioService.initialize(); }); @@ -118,9 +126,9 @@ describe('AudioService', () => { allowsRecordingIOS: true, staysActiveInBackground: true, playsInSilentModeIOS: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: true, - interruptionModeIOS: 'doNotMix', + shouldDuckAndroid: false, + playThroughEarpieceAndroid: false, + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, }); }); @@ -129,7 +137,6 @@ describe('AudioService', () => { }); it('should load all audio files', () => { - expect(mockAssetFromModule).toHaveBeenCalledTimes(5); expect(mockSoundCreateAsync).toHaveBeenCalledTimes(5); }); }); @@ -140,19 +147,18 @@ describe('AudioService', () => { await audioService.playStartTransmittingSound(); - expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0); - expect(mockSound.playAsync).toHaveBeenCalled(); + expect(mockSound.replayAsync).toHaveBeenCalled(); }); it('should handle start transmitting sound playback errors', async () => { jest.clearAllMocks(); - mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed')); + mockSound.replayAsync.mockRejectedValueOnce(new Error('Playback failed')); await audioService.playStartTransmittingSound(); - expect(logger.error).toHaveBeenCalledWith({ - message: 'Failed to play sound', - context: { soundName: 'startTransmitting', error: expect.any(Error) }, + expect(logger.warn).toHaveBeenCalledWith({ + message: 'Failed to play startTransmitting sound', + context: { error: expect.any(Error) }, }); }); }); @@ -163,19 +169,18 @@ describe('AudioService', () => { await audioService.playStopTransmittingSound(); - expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0); - expect(mockSound.playAsync).toHaveBeenCalled(); + expect(mockSound.replayAsync).toHaveBeenCalled(); }); it('should handle stop transmitting sound playback errors', async () => { jest.clearAllMocks(); - mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed')); + mockSound.replayAsync.mockRejectedValueOnce(new Error('Playback failed')); await audioService.playStopTransmittingSound(); - expect(logger.error).toHaveBeenCalledWith({ - message: 'Failed to play sound', - context: { soundName: 'stopTransmitting', error: expect.any(Error) }, + expect(logger.warn).toHaveBeenCalledWith({ + message: 'Failed to play stopTransmitting sound', + context: { error: expect.any(Error) }, }); }); }); @@ -186,19 +191,18 @@ describe('AudioService', () => { await audioService.playConnectedDeviceSound(); - expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0); - expect(mockSound.playAsync).toHaveBeenCalled(); + expect(mockSound.replayAsync).toHaveBeenCalled(); }); it('should handle connected device sound playback errors', async () => { jest.clearAllMocks(); - mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed')); + mockSound.replayAsync.mockRejectedValueOnce(new Error('Playback failed')); await audioService.playConnectedDeviceSound(); - expect(logger.error).toHaveBeenCalledWith({ - message: 'Failed to play sound', - context: { soundName: 'connectedDevice', error: expect.any(Error) }, + expect(logger.warn).toHaveBeenCalledWith({ + message: 'Failed to play connectedDevice sound', + context: { error: expect.any(Error) }, }); }); }); @@ -209,19 +213,18 @@ describe('AudioService', () => { await audioService.playConnectToAudioRoomSound(); - expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0); - expect(mockSound.playAsync).toHaveBeenCalled(); + expect(mockSound.replayAsync).toHaveBeenCalled(); }); it('should handle connect to audio room sound playback errors', async () => { jest.clearAllMocks(); - mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed')); + mockSound.replayAsync.mockRejectedValueOnce(new Error('Playback failed')); await audioService.playConnectToAudioRoomSound(); - expect(logger.error).toHaveBeenCalledWith({ - message: 'Failed to play sound', - context: { soundName: 'connectedToAudioRoom', error: expect.any(Error) }, + expect(logger.warn).toHaveBeenCalledWith({ + message: 'Failed to play connectToAudioRoom sound', + context: { error: expect.any(Error) }, }); }); }); @@ -232,19 +235,18 @@ describe('AudioService', () => { await audioService.playDisconnectedFromAudioRoomSound(); - expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0); - expect(mockSound.playAsync).toHaveBeenCalled(); + expect(mockSound.replayAsync).toHaveBeenCalled(); }); it('should handle disconnected from audio room sound playback errors', async () => { jest.clearAllMocks(); - mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed')); + mockSound.replayAsync.mockRejectedValueOnce(new Error('Playback failed')); await audioService.playDisconnectedFromAudioRoomSound(); - expect(logger.error).toHaveBeenCalledWith({ - message: 'Failed to play sound', - context: { soundName: 'disconnectedFromAudioRoom', error: expect.any(Error) }, + expect(logger.warn).toHaveBeenCalledWith({ + message: 'Failed to play disconnectedFromAudioRoom sound', + context: { error: expect.any(Error) }, }); }); }); @@ -285,35 +287,23 @@ describe('AudioService', () => { describe('error handling', () => { it('should handle null sound objects gracefully', async () => { // Create a new service instance with createAsync that doesn't return sound - jest.clearAllMocks(); - delete require.cache[require.resolve('../audio.service')]; - - // Mock createAsync to return null sound to simulate failed sound creation (mockSoundCreateAsync as jest.MockedFunction).mockResolvedValue({ sound: null, status: {} }); - const AudioServiceModule = require('../audio.service'); - const testService = AudioServiceModule.audioService; - - (testService as any).isInitialized = false; - await testService.initialize(); - await testService.playStartTransmittingSound(); + // Reset state for this test + (audioService as any).isInitialized = false; + await audioService.initialize(); + await audioService.playStartTransmittingSound(); - expect(logger.warn).toHaveBeenCalledWith({ - message: 'Sound not loaded: startTransmitting', - }); + // Implementation returns silently if sound is null + expect(logger.warn).not.toHaveBeenCalled(); }); it('should handle initialization failures', async () => { - jest.clearAllMocks(); (mockAudioSetAudioModeAsync as jest.MockedFunction).mockRejectedValueOnce(new Error('Audio mode failed')); - // Re-import to trigger new initialization - delete require.cache[require.resolve('../audio.service')]; - const AudioServiceModule = require('../audio.service'); - const testService = AudioServiceModule.audioService; - - (testService as any).isInitialized = false; - await testService.initialize(); + // Reset state for this test + (audioService as any).isInitialized = false; + await audioService.initialize(); expect(logger.error).toHaveBeenCalledWith({ message: 'Failed to initialize audio service', diff --git a/src/services/__tests__/bluetooth-audio-b01inrico.test.ts b/src/services/__tests__/bluetooth-audio-b01inrico.test.ts index e3ad4df3..c21166e2 100644 --- a/src/services/__tests__/bluetooth-audio-b01inrico.test.ts +++ b/src/services/__tests__/bluetooth-audio-b01inrico.test.ts @@ -188,6 +188,20 @@ describe('BluetoothAudioService - B01 Inrico Button Parsing', () => { }); }); + it('should ignore long press on PTT stop (0x80)', () => { + const buffer = Buffer.from([0x80]); // PTT stop (0x00) with long press flag (0x80) + + const result = service.parseB01InricoButtonData(buffer); + + // Should be ignored (unknown) to prevent stopping PTT while holding + expect(result).toEqual({ + type: 'long_press', + button: 'unknown', + timestamp: expect.any(Number), + }); + }); + + it('should handle unknown button codes gracefully', () => { const buffer = Buffer.from([0x7F]); // Unknown button code without long press flag diff --git a/src/services/__tests__/callkeep.service.ios.test.ts b/src/services/__tests__/callkeep.service.ios.test.ts index 9b86dcd4..6aa6db23 100644 --- a/src/services/__tests__/callkeep.service.ios.test.ts +++ b/src/services/__tests__/callkeep.service.ios.test.ts @@ -167,9 +167,14 @@ describe('CallKeepService', () => { // Trigger mute event if (muteEventHandler) { + // First mute event + const now = 10000; + jest.spyOn(Date, 'now').mockReturnValue(now); muteEventHandler({ muted: true, callUUID: 'test-uuid' }); expect(mockMuteCallback).toHaveBeenCalledWith(true); + // Second mute event - advance time > 500ms to bypass storm protection + jest.spyOn(Date, 'now').mockReturnValue(now + 600); muteEventHandler({ muted: false, callUUID: 'test-uuid' }); expect(mockMuteCallback).toHaveBeenCalledWith(false); } diff --git a/src/services/app-initialization.service.ts b/src/services/app-initialization.service.ts index 2dd7c910..b05f9c1e 100644 --- a/src/services/app-initialization.service.ts +++ b/src/services/app-initialization.service.ts @@ -86,12 +86,12 @@ class AppInitializationService { } /** - * Initialize CallKeep service for iOS + * Initialize CallKeep service for iOS and Android */ private async _initializeCallKeep(): Promise { - if (Platform.OS !== 'ios') { + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { logger.debug({ - message: 'CallKeep initialization skipped - not iOS platform', + message: 'CallKeep initialization skipped - not supported platform', context: { platform: Platform.OS }, }); return; diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index 8e841baa..e274a8b5 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -10,7 +10,7 @@ import { logger } from '@/lib/logging'; import { storage } from '@/lib/storage'; import { removeActiveCallId, removeActiveUnitId, removeDeviceUuid } from '@/lib/storage/app'; import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; -import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; +import { INITIAL_STATE as BLUETOOTH_INITIAL_STATE, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { useCoreStore } from '@/stores/app/core-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; import { useLoadingStore } from '@/stores/app/loading-store'; @@ -153,14 +153,7 @@ export const INITIAL_AUDIO_STREAM_STATE = { isBottomSheetVisible: false, }; -export const INITIAL_BLUETOOTH_AUDIO_STATE = { - connectedDevice: null, - isScanning: false, - isConnecting: false, - availableDevices: [] as never[], - connectionError: null, - isAudioRoutingActive: false, -}; +export const INITIAL_BLUETOOTH_AUDIO_STATE = BLUETOOTH_INITIAL_STATE; export const INITIAL_PUSH_NOTIFICATION_MODAL_STATE = { isOpen: false, @@ -227,7 +220,7 @@ export const resetAllStores = async (): Promise => { }); } } - useLiveKitStore.setState(INITIAL_LIVEKIT_STATE, true); + useLiveKitStore.setState(INITIAL_LIVEKIT_STATE); // Audio stream store - await async cleanup, then reset const audioStreamState = useAudioStreamStore.getState(); @@ -239,13 +232,13 @@ export const resetAllStores = async (): Promise => { context: { error }, }); } - useAudioStreamStore.setState(INITIAL_AUDIO_STREAM_STATE, true); + useAudioStreamStore.setState(INITIAL_AUDIO_STREAM_STATE); // Bluetooth audio store - reset - useBluetoothAudioStore.setState(INITIAL_BLUETOOTH_AUDIO_STATE, true); + useBluetoothAudioStore.setState(INITIAL_BLUETOOTH_AUDIO_STATE); // Push notification modal store - reset - usePushNotificationModalStore.setState(INITIAL_PUSH_NOTIFICATION_MODAL_STATE, true); + usePushNotificationModalStore.setState(INITIAL_PUSH_NOTIFICATION_MODAL_STATE); }; /** diff --git a/src/services/audio.service.ts b/src/services/audio.service.ts index d9a934fa..76bf5ea6 100644 --- a/src/services/audio.service.ts +++ b/src/services/audio.service.ts @@ -1,16 +1,19 @@ import { Asset } from 'expo-asset'; -import { Audio, type AVPlaybackSource, InterruptionModeIOS } from 'expo-av'; +import { Audio, InterruptionModeIOS } from 'expo-av'; import { Platform } from 'react-native'; import { logger } from '@/lib/logging'; class AudioService { private static instance: AudioService; + + // Expo AV Sound objects private startTransmittingSound: Audio.Sound | null = null; private stopTransmittingSound: Audio.Sound | null = null; private connectedDeviceSound: Audio.Sound | null = null; private connectToAudioRoomSound: Audio.Sound | null = null; private disconnectedFromAudioRoomSound: Audio.Sound | null = null; + private isInitialized = false; private constructor() { @@ -34,15 +37,28 @@ class AudioService { } try { - // Configure audio mode for production builds await Audio.setAudioModeAsync({ allowsRecordingIOS: true, staysActiveInBackground: true, playsInSilentModeIOS: true, - shouldDuckAndroid: true, - playThroughEarpieceAndroid: true, - interruptionModeIOS: InterruptionModeIOS.DoNotMix, - }); + shouldDuckAndroid: false, + playThroughEarpieceAndroid: false, + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, + }); + + // Initialize Native In-Call Audio Module on Android + if (Platform.OS === 'android') { + const { InCallAudioModule } = require('react-native').NativeModules; + if (InCallAudioModule) { + // Load sounds into native SoundPool + // Map functional names to resource names (without extension) + InCallAudioModule.loadSound('startTransmitting', 'software_interface_start'); + InCallAudioModule.loadSound('stopTransmitting', 'software_interface_back'); + InCallAudioModule.loadSound('connectedDevice', 'positive_interface_beep'); + InCallAudioModule.loadSound('connectToAudioRoom', 'space_notification1'); + InCallAudioModule.loadSound('disconnectedFromAudioRoom', 'space_notification2'); + } + } // Pre-load audio assets for production builds await this.preloadAudioAssets(); @@ -84,62 +100,36 @@ class AudioService { } } - private async loadAudioFiles(): Promise { + private async loadSound(module: any): Promise { try { - // Load start transmitting sound - const startTransmittingSoundAsset = Asset.fromModule(require('@assets/audio/ui/space_notification1.mp3')); - await startTransmittingSoundAsset.downloadAsync(); - - const { sound: startSound } = await Audio.Sound.createAsync({ uri: startTransmittingSoundAsset.localUri || startTransmittingSoundAsset.uri } as AVPlaybackSource, { - shouldPlay: false, - isLooping: false, - volume: 1.0, - }); - this.startTransmittingSound = startSound; - - // Load stop transmitting sound - const stopTransmittingSoundAsset = Asset.fromModule(require('@assets/audio/ui/space_notification2.mp3')); - await stopTransmittingSoundAsset.downloadAsync(); - - const { sound: stopSound } = await Audio.Sound.createAsync({ uri: stopTransmittingSoundAsset.localUri || stopTransmittingSoundAsset.uri } as AVPlaybackSource, { - shouldPlay: false, - isLooping: false, - volume: 1.0, - }); - this.stopTransmittingSound = stopSound; - - // Load connected device sound - const connectedDeviceSoundAsset = Asset.fromModule(require('@assets/audio/ui/positive_interface_beep.mp3')); - await connectedDeviceSoundAsset.downloadAsync(); - - const { sound: connectedSound } = await Audio.Sound.createAsync({ uri: connectedDeviceSoundAsset.localUri || connectedDeviceSoundAsset.uri } as AVPlaybackSource, { - shouldPlay: false, - isLooping: false, - volume: 1.0, + const { sound } = await Audio.Sound.createAsync(module); + return sound; + } catch (error) { + logger.error({ + message: 'Error loading sound', + context: { error }, }); - this.connectedDeviceSound = connectedSound; - - // Load connect to audio room sound - const connectToAudioRoomSoundAsset = Asset.fromModule(require('@assets/audio/ui/software_interface_start.mp3')); - await connectToAudioRoomSoundAsset.downloadAsync(); + return null; + } + } - const { sound: connectToRoomSound } = await Audio.Sound.createAsync({ uri: connectToAudioRoomSoundAsset.localUri || connectToAudioRoomSoundAsset.uri } as AVPlaybackSource, { - shouldPlay: false, - isLooping: false, - volume: 1.0, - }); - this.connectToAudioRoomSound = connectToRoomSound; + private async loadAudioFiles(): Promise { + try { + const [startTransmittingSound, stopTransmittingSound, connectedDeviceSound, connectToAudioRoomSound, disconnectedFromAudioRoomSound] = await Promise.all([ + this.loadSound(require('@assets/audio/ui/space_notification1.mp3')), + this.loadSound(require('@assets/audio/ui/space_notification2.mp3')), + this.loadSound(require('@assets/audio/ui/positive_interface_beep.mp3')), + this.loadSound(require('@assets/audio/ui/software_interface_start.mp3')), + this.loadSound(require('@assets/audio/ui/software_interface_back.mp3')), + ]); - // Load disconnect from audio room sound - const disconnectedFromAudioRoomSoundAsset = Asset.fromModule(require('@assets/audio/ui/software_interface_back.mp3')); - await disconnectedFromAudioRoomSoundAsset.downloadAsync(); + this.startTransmittingSound = startTransmittingSound; + this.stopTransmittingSound = stopTransmittingSound; + this.connectedDeviceSound = connectedDeviceSound; + this.connectToAudioRoomSound = connectToAudioRoomSound; + this.disconnectedFromAudioRoomSound = disconnectedFromAudioRoomSound; - const { sound: disconnectFromRoomSound } = await Audio.Sound.createAsync({ uri: disconnectedFromAudioRoomSoundAsset.localUri || disconnectedFromAudioRoomSoundAsset.uri } as AVPlaybackSource, { - shouldPlay: false, - isLooping: false, - volume: 1.0, - }); - this.disconnectedFromAudioRoomSound = disconnectFromRoomSound; + this.isInitialized = true; logger.debug({ message: 'Audio files loaded successfully', @@ -152,122 +142,65 @@ class AudioService { } } - private async playSound(sound: Audio.Sound | null, soundName: string): Promise { - try { - if (!sound) { - logger.warn({ - message: `Sound not loaded: ${soundName}`, - }); + private async playSound(sound: Audio.Sound | null, name: string): Promise { + if (Platform.OS === 'android') { + const { InCallAudioModule } = require('react-native').NativeModules; + if (InCallAudioModule) { + InCallAudioModule.playSound(name); + logger.debug({ message: 'Played sound via Native Module', context: { soundName: name } }); return; } - - // Ensure audio service is initialized - if (!this.isInitialized) { - await this.initializeAudio(); - } - - // Reset to start and play - await sound.setPositionAsync(0); - await sound.playAsync(); - - logger.debug({ - message: 'Sound played successfully', - context: { soundName }, - }); - } catch (error) { - logger.error({ - message: 'Failed to play sound', - context: { soundName, error }, - }); } - } - async playStartTransmittingSound(): Promise { + if (!sound) return; try { - await this.playSound(this.startTransmittingSound, 'startTransmitting'); + await sound.replayAsync(); + logger.debug({ message: 'Sound played successfully', context: { soundName: name } }); } catch (error) { - logger.error({ - message: 'Failed to play start transmitting sound', + logger.warn({ + message: `Failed to play ${name} sound`, context: { error }, }); } } + async playStartTransmittingSound(): Promise { + await this.playSound(this.startTransmittingSound, 'startTransmitting'); + } + async playStopTransmittingSound(): Promise { - try { - await this.playSound(this.stopTransmittingSound, 'stopTransmitting'); - } catch (error) { - logger.error({ - message: 'Failed to play stop transmitting sound', - context: { error }, - }); - } + await this.playSound(this.stopTransmittingSound, 'stopTransmitting'); } async playConnectedDeviceSound(): Promise { - try { - await this.playSound(this.connectedDeviceSound, 'connectedDevice'); - } catch (error) { - logger.error({ - message: 'Failed to play connected device sound', - context: { error }, - }); - } + await this.playSound(this.connectedDeviceSound, 'connectedDevice'); } async playConnectToAudioRoomSound(): Promise { - try { - await this.playSound(this.connectToAudioRoomSound, 'connectedToAudioRoom'); - } catch (error) { - logger.error({ - message: 'Failed to play connected to audio room sound', - context: { error }, - }); - } + await this.playSound(this.connectToAudioRoomSound, 'connectToAudioRoom'); } async playDisconnectedFromAudioRoomSound(): Promise { - try { - await this.playSound(this.disconnectedFromAudioRoomSound, 'disconnectedFromAudioRoom'); - } catch (error) { - logger.error({ - message: 'Failed to play disconnected from audio room sound', - context: { error }, - }); - } + await this.playSound(this.disconnectedFromAudioRoomSound, 'disconnectedFromAudioRoom'); } async cleanup(): Promise { try { - // Unload start transmitting sound - if (this.startTransmittingSound) { - await this.startTransmittingSound.unloadAsync(); - this.startTransmittingSound = null; - } - - // Unload stop transmitting sound - if (this.stopTransmittingSound) { - await this.stopTransmittingSound.unloadAsync(); - this.stopTransmittingSound = null; - } - - // Unload connected device sound - if (this.connectedDeviceSound) { - await this.connectedDeviceSound.unloadAsync(); - this.connectedDeviceSound = null; - } - - // Unload connect to audio room sound - if (this.connectToAudioRoomSound) { - await this.connectToAudioRoomSound.unloadAsync(); - this.connectToAudioRoomSound = null; - } - - // Unload disconnect from audio room sound - if (this.disconnectedFromAudioRoomSound) { - await this.disconnectedFromAudioRoomSound.unloadAsync(); - this.disconnectedFromAudioRoomSound = null; - } + const sounds = [this.startTransmittingSound, this.stopTransmittingSound, this.connectedDeviceSound, this.connectToAudioRoomSound, this.disconnectedFromAudioRoomSound]; + + await Promise.all( + sounds.map(async (sound) => { + if (sound) { + await sound.unloadAsync(); + } + }) + ); + + this.startTransmittingSound = null; + this.stopTransmittingSound = null; + this.connectedDeviceSound = null; + this.connectToAudioRoomSound = null; + this.disconnectedFromAudioRoomSound = null; this.isInitialized = false; diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index 9c5be3a3..8c5cc8e2 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -1,9 +1,11 @@ import { Buffer } from 'buffer'; +// @ts-ignore - callkeep service might not be resolvable in all contexts without barrel file updates import { Alert, DeviceEventEmitter, PermissionsAndroid, Platform } from 'react-native'; import BleManager, { type BleManagerDidUpdateValueForCharacteristicEvent, BleScanCallbackType, BleScanMatchMode, BleScanMode, type BleState, type Peripheral, type PeripheralInfo } from 'react-native-ble-manager'; import { logger } from '@/lib/logging'; import { audioService } from '@/services/audio.service'; +import { callKeepService } from '@/services/callkeep.service'; import { type AudioButtonEvent, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; @@ -225,10 +227,14 @@ class BluetoothAudioService { } // Define RSSI threshold for strong signals (typical range: -100 to -20 dBm) - const STRONG_RSSI_THRESHOLD = -60; // Only allow devices with RSSI stronger than -60 dBm + const STRONG_RSSI_THRESHOLD = -95; // Relaxed threshold to improve discovery // Check RSSI signal strength - only proceed with strong signals if (!device.rssi || device.rssi < STRONG_RSSI_THRESHOLD) { + logger.debug({ + message: 'Device ignored due to weak RSSI', + context: { deviceId: device.id, rssi: device.rssi, threshold: STRONG_RSSI_THRESHOLD }, + }); return; } @@ -254,12 +260,13 @@ class BluetoothAudioService { const value = Buffer.from(data.value).toString('base64'); logger.debug({ - message: 'Characteristic value updated', + message: '[DEBUG_VALUE_UPDATE] Characteristic value updated', context: { peripheral: data.peripheral, service: data.service, characteristic: data.characteristic, - value: Buffer.from(data.value).toString('hex'), + valueHex: Buffer.from(data.value).toString('hex'), + valueBase64: value, }, }); @@ -275,17 +282,14 @@ class BluetoothAudioService { } private handleButtonEventFromCharacteristic(serviceUuid: string, characteristicUuid: string, value: string): void { - const upperServiceUuid = serviceUuid.toUpperCase(); - const upperCharUuid = characteristicUuid.toUpperCase(); - // Route to appropriate handler based on service/characteristic - if (upperServiceUuid === AINA_HEADSET_SERVICE.toUpperCase() && upperCharUuid === AINA_HEADSET_SVC_PROP.toUpperCase()) { + if (this.areUuidsEqual(serviceUuid, AINA_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, AINA_HEADSET_SVC_PROP)) { this.handleAinaButtonEvent(value); - } else if (upperServiceUuid === B01INRICO_HEADSET_SERVICE.toUpperCase() && upperCharUuid === B01INRICO_HEADSET_SERVICE_CHAR.toUpperCase()) { + } else if (this.areUuidsEqual(serviceUuid, B01INRICO_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, B01INRICO_HEADSET_SERVICE_CHAR)) { this.handleB01InricoButtonEvent(value); - } else if (upperServiceUuid === HYS_HEADSET_SERVICE.toUpperCase() && upperCharUuid === HYS_HEADSET_SERVICE_CHAR.toUpperCase()) { + } else if (this.areUuidsEqual(serviceUuid, HYS_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, HYS_HEADSET_SERVICE_CHAR)) { this.handleHYSButtonEvent(value); - } else if (BUTTON_CONTROL_UUIDS.some((uuid) => uuid.toUpperCase() === upperCharUuid)) { + } else if (BUTTON_CONTROL_UUIDS.some((uuid) => this.areUuidsEqual(characteristicUuid, uuid))) { this.handleGenericButtonEvent(value); } } @@ -417,7 +421,7 @@ class BluetoothAudioService { } // Stop any existing scan first - this.stopScanning(); + await this.stopScanning(); useBluetoothAudioStore.getState().setIsScanning(true); useBluetoothAudioStore.getState().clearDevices(); @@ -818,9 +822,9 @@ class BluetoothAudioService { return serviceUUIDs.some((uuid: string) => [HFP_SERVICE_UUID, HSP_SERVICE_UUID].includes(uuid.toUpperCase())); } - stopScanning(): void { + async stopScanning(): Promise { try { - BleManager.stopScan(); + await BleManager.stopScan(); } catch (error) { logger.debug({ message: 'Error stopping scan', @@ -842,9 +846,21 @@ class BluetoothAudioService { async connectToDevice(deviceId: string): Promise { try { + useBluetoothAudioStore.getState().clearConnectionError(); useBluetoothAudioStore.getState().setIsConnecting(true); + // Ensure scanning is stopped before connecting + // Connecting while scanning often fails on Android + await this.stopScanning(); + + // Small delay to allow radio to switch modes + await new Promise((resolve) => setTimeout(resolve, 500)); + // Connect to the device + logger.info({ + message: 'Attempting to connect to device via BleManager', + context: { deviceId }, + }); await BleManager.connect(deviceId); logger.info({ @@ -861,7 +877,19 @@ class BluetoothAudioService { } // Discover services and characteristics + logger.info({ + message: 'Retrieving services which triggers discovery', + context: { deviceId }, + }); const peripheralInfo = await BleManager.retrieveServices(deviceId); + logger.info({ + message: 'Services retrieved successfully', + context: { + deviceId, + serviceCount: peripheralInfo.services?.length, + services: peripheralInfo.services?.map((s: any) => s.uuid), + }, + }); this.connectedDevice = device; useBluetoothAudioStore.getState().setConnectedDevice({ @@ -885,20 +913,40 @@ class BluetoothAudioService { useBluetoothAudioStore.getState().setIsConnecting(false); } catch (error) { + // Extract meaningful error message + let errorMessage = 'Unknown connection error'; + + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'string') { + errorMessage = error; + } else if (typeof error === 'object' && error !== null) { + // Try to find a message property or basic string representation + if ('message' in error && typeof (error as any).message === 'string') { + errorMessage = (error as any).message; + } else { + try { + errorMessage = JSON.stringify(error); + } catch { + errorMessage = String(error); + } + } + } + logger.error({ message: 'Failed to connect to Bluetooth audio device', - context: { deviceId, error }, + context: { deviceId, error, errorMessage }, }); useBluetoothAudioStore.getState().setIsConnecting(false); - useBluetoothAudioStore.getState().setConnectionError(error instanceof Error ? error.message : 'Unknown connection error'); + useBluetoothAudioStore.getState().setConnectionError(errorMessage); throw error; } } private handleDeviceDisconnected(args: { peripheral: string }): void { logger.info({ - message: 'Bluetooth audio device disconnected', + message: '[DISCONNECT EVENT] Bluetooth audio device disconnected', context: { deviceId: args.peripheral, }, @@ -945,13 +993,53 @@ class BluetoothAudioService { return false; } - const service = peripheralInfo.services.find((s: any) => s.uuid?.toUpperCase() === serviceUuid.toUpperCase()); - - if (!service || !(service as any).characteristics) { + if (!peripheralInfo.characteristics) { return false; } - return (service as any).characteristics.some((c: any) => c.characteristic?.toUpperCase() === characteristicUuid.toUpperCase()); + const characteristicFound = peripheralInfo.characteristics.some((c: any) => this.areUuidsEqual(c.service, serviceUuid) && this.areUuidsEqual(c.characteristic, characteristicUuid)); + + logger.debug({ + message: '[DEBUG_MATCH] Checking characteristic', + context: { + lookingForService: serviceUuid, + lookingForChar: characteristicUuid, + characteristicFound, + }, + }); + + return characteristicFound; + } + + /** + * Compare two UUIDs handling 16-bit and 128-bit formats + */ + private areUuidsEqual(uuid1: string, uuid2: string): boolean { + if (!uuid1 || !uuid2) return false; + + const u1 = uuid1.replace(/-/g, '').toUpperCase(); + const u2 = uuid2.replace(/-/g, '').toUpperCase(); + + // If lengths match, just compare + if (u1.length === u2.length) { + return u1 === u2; + } + + // Handle 16-bit vs 128-bit comparison + // 16-bit UUIDs are often returned as 4 characters, while 128-bit are 32 chars + // Standard base UUID: 0000xxxx-0000-1000-8000-00805F9B34FB + + const longUuid = u1.length > u2.length ? u1 : u2; + const shortUuid = u1.length > u2.length ? u2 : u1; + + // If short UUID is 4 chars (16-bit), check if it matches the 128-bit pattern + if (shortUuid.length === 4 && longUuid.length === 32) { + // Construct expected 128-bit UUID from 16-bit UUID + const expectedLong = `0000${shortUuid}00001000800000805F9B34FB`; + return longUuid === expectedLong; + } + + return false; } private async startNotificationsForButtonControls(deviceId: string, peripheralInfo: PeripheralInfo): Promise { @@ -964,6 +1052,11 @@ class BluetoothAudioService { ...BUTTON_CONTROL_UUIDS.map((uuid) => ({ service: '00001800-0000-1000-8000-00805F9B34FB', characteristic: uuid })), // Generic service ]; + logger.debug({ + message: 'Iterating button control configs', + context: { configCount: buttonControlConfigs.length }, + }); + for (const config of buttonControlConfigs) { try { // Check if the characteristic exists before trying to start notifications @@ -989,7 +1082,8 @@ class BluetoothAudioService { }, }); } catch (error) { - logger.debug({ + logger.warn({ + // Changed to warn to make it more visible message: 'Failed to start notifications for characteristic', context: { deviceId, @@ -1161,6 +1255,9 @@ class BluetoothAudioService { }, }); + // FORCE LOG COMPATIBILITY + console.log(`[PTT_DEBUG] Raw Buffer: ${rawHex} | Bytes: ${allBytes}`); + // B01 Inrico-specific parsing logic const byte = buffer[0]; const byte2 = buffer[5] || 0; // Fallback to 0 if not present @@ -1251,7 +1348,9 @@ class BluetoothAudioService { // Re-check button mapping with the actual button byte (without long press flag) switch (actualButtonByte) { case 0x00: - buttonType = 'ptt_stop'; + // Ignore 0x80 (Long press on 0x00). This is often sent while holding PTT + // and should NOT be interpreted as a STOP command. + buttonType = 'unknown'; break; case 0x01: buttonType = 'ptt_start'; @@ -1406,11 +1505,16 @@ class BluetoothAudioService { } if (buttonEvent.button === 'ptt_start') { + // Proactively lock CallKeep events to prevent HFP interactions/spam + // when we are explicitly handling PTT via SPP/GATT + callKeepService.ignoreMuteEvents(1000); this.setMicrophoneEnabled(true); return; } if (buttonEvent.button === 'ptt_stop') { + // Keep locked for a bit after release to handle trailing events + callKeepService.ignoreMuteEvents(1000); this.setMicrophoneEnabled(false); return; } @@ -1600,11 +1704,12 @@ class BluetoothAudioService { async disconnectDevice(): Promise { if (this.connectedDevice && this.connectedDevice.id) { + const deviceId = this.connectedDevice.id; try { - await BleManager.disconnect(this.connectedDevice.id); + await BleManager.disconnect(deviceId); logger.info({ message: 'Bluetooth audio device disconnected manually', - context: { deviceId: this.connectedDevice.id }, + context: { deviceId }, }); } catch (error) { logger.error({ @@ -1613,7 +1718,7 @@ class BluetoothAudioService { }); } - this.handleDeviceDisconnected({ peripheral: this.connectedDevice.id }); + this.handleDeviceDisconnected({ peripheral: deviceId }); } } @@ -1686,8 +1791,43 @@ class BluetoothAudioService { // Reset initialization flags this.isInitialized = false; + this.isInitialized = false; this.hasAttemptedPreferredDeviceConnection = false; } + + /** + * Fully reset the Bluetooth service state. + * Clears connections, scanning, and preferred device tracking. + */ + async reset(): Promise { + logger.info({ + message: 'Resetting Bluetooth Audio Service state', + }); + + try { + await this.stopScanning(); + await this.disconnectDevice(); + + this.connectedDevice = null; + this.hasAttemptedPreferredDeviceConnection = false; + + // Revert LiveKit audio routing + this.revertLiveKitAudioRouting(); + + const store = useBluetoothAudioStore.getState(); + store.clearDevices(); + store.setConnectedDevice(null); + store.setPreferredDevice(null); + store.clearConnectionError(); + store.setIsConnecting(false); + store.setIsScanning(false); + } catch (error) { + logger.error({ + message: 'Error resetting Bluetooth Audio Service', + context: { error }, + }); + } + } } export const bluetoothAudioService = BluetoothAudioService.getInstance(); diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts index f10ab868..d78c7ba1 100644 --- a/src/services/callkeep.service.android.ts +++ b/src/services/callkeep.service.android.ts @@ -1,5 +1,11 @@ +import { Platform } from 'react-native'; +import RNCallKeep, { AudioSessionCategoryOption, AudioSessionMode, CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep'; + import { logger } from '../lib/logging'; +// UUID for the CallKeep call - should be unique per session +let currentCallUUID: string | null = null; + export interface CallKeepConfig { appName: string; maximumCallGroups: number; @@ -9,12 +15,11 @@ export interface CallKeepConfig { ringtoneSound?: string; } -/** - * No-op implementation of CallKeepService for Android - * CallKeep functionality is only supported on iOS - */ export class CallKeepService { private static instance: CallKeepService | null = null; + private isSetup = false; + private isCallActive = false; + private muteStateCallback: ((muted: boolean) => void) | null = null; private constructor() {} @@ -25,43 +30,294 @@ export class CallKeepService { return CallKeepService.instance; } - async setup(_config: CallKeepConfig): Promise { - logger.debug({ - message: 'CallKeep setup skipped - not supported on Android', - }); + /** + * Setup CallKeep with the required configuration + * This should be called once during app initialization + */ + async setup(config: CallKeepConfig): Promise { + if (Platform.OS !== 'android') { + logger.debug({ + message: 'CallKeep setup skipped - not Android platform', + context: { platform: Platform.OS }, + }); + return; + } + + if (this.isSetup) { + logger.debug({ + message: 'CallKeep already setup', + }); + return; + } + + try { + const options = { + ios: { + appName: config.appName, + maximumCallGroups: config.maximumCallGroups.toString(), + maximumCallsPerCallGroup: config.maximumCallsPerCallGroup.toString(), + includesCallsInRecents: config.includesCallsInRecents, + supportsVideo: config.supportsVideo, + ringtoneSound: config.ringtoneSound, + audioSession: { + categoryOptions: AudioSessionCategoryOption.allowAirPlay + AudioSessionCategoryOption.allowBluetooth + AudioSessionCategoryOption.allowBluetoothA2DP + AudioSessionCategoryOption.defaultToSpeaker, + mode: AudioSessionMode.voiceChat, + }, + }, + android: { + alertTitle: 'Permissions required', + alertDescription: 'This application needs to access your phone accounts', + cancelButton: 'Cancel', + okButton: 'OK', + imageName: 'phone_account_icon', + additionalPermissions: [], + // Self-managed connection service for VoIP apps + selfManaged: true, + foregroundService: { + channelId: 'com.resgrid.unit.voip', + channelName: 'Voice Calls', + notificationTitle: 'Resgrid Unit Voice Call', + notificationIcon: 'ic_launcher', + }, + }, + }; + + await RNCallKeep.setup(options); + + // Essential for Android to show the app as capable of calls + RNCallKeep.setAvailable(true); + + this.setupEventListeners(); + this.isSetup = true; + + logger.info({ + message: 'CallKeep setup completed successfully (Android)', + context: { config }, + }); + } catch (error) { + logger.error({ + message: 'Failed to setup CallKeep (Android)', + context: { error, config }, + }); + // Don't throw, just log. We don't want to crash app init. + } } - async startCall(_roomName: string, _handle?: string): Promise { - logger.debug({ - message: 'CallKeep startCall skipped - not supported on Android', - }); - return ''; + /** + * Start a CallKit call to keep the app alive in the background + * This should be called when connecting to a LiveKit room + */ + async startCall(roomName: string, handle?: string): Promise { + if (Platform.OS !== 'android') { + return ''; + } + + if (!this.isSetup) { + // Auto-setup if not done (defensive programming) + await this.setup({ + appName: 'Resgrid Unit', + maximumCallGroups: 1, + maximumCallsPerCallGroup: 1, + includesCallsInRecents: false, + supportsVideo: false, + }); + } + + if (currentCallUUID) { + logger.debug({ + message: 'Existing call UUID found, ending before starting a new one', + context: { currentCallUUID }, + }); + await this.endCall(); + } + + try { + // Generate a new UUID for this call + currentCallUUID = this.generateUUID(); + const callHandle = handle || 'Voice Channel'; + const contactIdentifier = `Voice Channel: ${roomName}`; + + logger.info({ + message: 'Starting CallKeep call (Android)', + context: { + uuid: currentCallUUID, + handle: callHandle, + roomName, + }, + }); + + // Start the call + RNCallKeep.startCall(currentCallUUID, callHandle, contactIdentifier, 'generic', false); + + // On Android, we should set the call active + RNCallKeep.setCurrentCallActive(currentCallUUID); + + this.isCallActive = true; + + return currentCallUUID; + } catch (error) { + logger.error({ + message: 'Failed to start CallKeep call (Android)', + context: { error, roomName, handle }, + }); + currentCallUUID = null; + throw error; + } } + /** + * End the active CallKit call + * This should be called when disconnecting from a LiveKit room + */ async endCall(): Promise { - logger.debug({ - message: 'CallKeep endCall skipped - not supported on Android', - }); + if (Platform.OS !== 'android') { + return; + } + + if (!currentCallUUID) { + return; + } + + try { + logger.info({ + message: 'Ending CallKeep call (Android)', + context: { uuid: currentCallUUID }, + }); + + RNCallKeep.endCall(currentCallUUID); + currentCallUUID = null; + this.isCallActive = false; + } catch (error) { + logger.error({ + message: 'Failed to end CallKeep call (Android)', + context: { error, uuid: currentCallUUID }, + }); + // Reset state even if ending failed + currentCallUUID = null; + this.isCallActive = false; + } } - setMuteStateCallback(_callback: ((muted: boolean) => void) | null): void { - logger.debug({ - message: 'CallKeep setMuteStateCallback skipped - not supported on Android', - }); + /** + * Set a callback to handle mute state changes from CallKit + * This should be called by the LiveKit store to sync mute state + */ + setMuteStateCallback(callback: ((muted: boolean) => void) | null): void { + this.muteStateCallback = callback; + } + + /** + * Externally lock/ignore mute events (No-op on Android for now) + */ + ignoreMuteEvents(durationMs: number): void { + // No-op on Android } + /** + * Check if there's an active CallKit call + */ isCallActiveNow(): boolean { - return false; + return this.isCallActive && currentCallUUID !== null; } + /** + * Get the current call UUID + */ getCurrentCallUUID(): string | null { - return null; + return currentCallUUID; } - async cleanup(): Promise { - logger.debug({ - message: 'CallKeep cleanup skipped - not supported on Android', + /** + * Setup event listeners for CallKeep events + */ + private setupEventListeners(): void { + // Call ended from CallKit UI + RNCallKeep.addEventListener('endCall', ({ callUUID }: { callUUID: string }) => { + logger.info({ + message: 'CallKeep call ended from system UI', + context: { callUUID }, + }); + + if (callUUID === currentCallUUID) { + currentCallUUID = null; + this.isCallActive = false; + } }); + + // Call answered (not typically used for outgoing calls, but good to handle) + RNCallKeep.addEventListener('answerCall', ({ callUUID }: { callUUID: string }) => { + logger.debug({ + message: 'CallKeep call answered', + context: { callUUID }, + }); + RNCallKeep.setCurrentCallActive(callUUID); + }); + + // Mute/unmute events + RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }: { muted: boolean; callUUID: string }) => { + 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 + */ + private generateUUID(): string { + // RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI + const rndUUID = (global as any)?.crypto?.randomUUID?.(); + if (typeof rndUUID === 'string') return rndUUID; + // Fallback + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + /** + * Clean up resources - call this when the service is no longer needed + */ + async cleanup(): Promise { + if (Platform.OS !== 'android') { + return; + } + + try { + if (this.isCallActive) { + await this.endCall(); + } + + // Remove event listeners + RNCallKeep.removeEventListener('endCall'); + RNCallKeep.removeEventListener('answerCall'); + RNCallKeep.removeEventListener('didPerformSetMutedCallAction'); + + this.isSetup = false; + + logger.debug({ + message: 'CallKeep service cleaned up', + }); + } catch (error) { + logger.error({ + message: 'Error during CallKeep cleanup', + context: { error }, + }); + } } } diff --git a/src/services/callkeep.service.ios.ts b/src/services/callkeep.service.ios.ts index 0f073d30..491a1392 100644 --- a/src/services/callkeep.service.ios.ts +++ b/src/services/callkeep.service.ios.ts @@ -21,6 +21,9 @@ export class CallKeepService { private isSetup = false; private isCallActive = false; private muteStateCallback: ((muted: boolean) => void) | null = null; + private lastMuteEventTime: number = 0; + private muteEventStormEndTime: number = 0; + private ignoreEventsUntil: number = 0; private constructor() {} @@ -212,6 +215,19 @@ export class CallKeepService { this.muteStateCallback = 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. + */ + ignoreMuteEvents(durationMs: number): void { + const now = Date.now(); + this.ignoreEventsUntil = Math.max(this.ignoreEventsUntil, now + durationMs); + logger.debug({ + message: 'CallKeep mute events ignored via external lock', + context: { durationMs, until: this.ignoreEventsUntil }, + }); + } + /** * Check if there's an active CallKit call */ @@ -269,6 +285,45 @@ export class CallKeepService { // Mute/unmute events RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }: { muted: boolean; callUUID: string }) => { + const now = Date.now(); + + // 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; + } + + const timeSinceLastEvent = now - this.lastMuteEventTime; + this.lastMuteEventTime = now; + + // 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: 'Ignored CallKeep mute state change (storm detected)', + context: { muted, callUUID, timeSinceLastEvent }, + }); + return; + } + + // 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 }, diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts index 674f8069..3cc28984 100644 --- a/src/stores/app/bluetooth-audio-store.ts +++ b/src/stores/app/bluetooth-audio-store.ts @@ -43,7 +43,7 @@ export interface ButtonAction { export interface AudioDeviceInfo { id: string; name: string; - type: 'bluetooth' | 'wired' | 'speaker' | 'default'; + type: 'bluetooth' | 'wired' | 'speaker' | 'default' | 'microphone'; isAvailable: boolean; } @@ -114,8 +114,31 @@ interface BluetoothAudioState { setMediaButtonPTTEnabled: (enabled: boolean) => void; } -export const useBluetoothAudioStore = create((set, get) => ({ - // Initial state +// Initial state +export const INITIAL_STATE: Omit< + BluetoothAudioState, + | 'setBluetoothState' + | 'setIsScanning' + | 'setIsConnecting' + | 'addDevice' + | 'updateDevice' + | 'removeDevice' + | 'clearDevices' + | 'setConnectedDevice' + | 'setPreferredDevice' + | 'setAvailableAudioDevices' + | 'setSelectedMicrophone' + | 'setSelectedSpeaker' + | 'updateAudioDeviceAvailability' + | 'setConnectionError' + | 'clearConnectionError' + | 'setAudioRoutingActive' + | 'addButtonEvent' + | 'clearButtonEvents' + | 'setLastButtonAction' + | 'setMediaButtonPTTSettings' + | 'setMediaButtonPTTEnabled' +> = { bluetoothState: State.Unknown, isScanning: false, isConnecting: false, @@ -123,11 +146,11 @@ export const useBluetoothAudioStore = create((set, get) => connectedDevice: null, preferredDevice: null, availableAudioDevices: [ - { id: 'default-mic', name: 'Default Microphone', type: 'default', isAvailable: true }, + { id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true }, { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true }, ], selectedAudioDevices: { - microphone: { id: 'default-mic', name: 'Default Microphone', type: 'default', isAvailable: true }, + microphone: { id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true }, speaker: { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true }, }, connectionError: null, @@ -135,6 +158,10 @@ export const useBluetoothAudioStore = create((set, get) => buttonEvents: [], lastButtonAction: null, mediaButtonPTTSettings: createDefaultPTTSettings(), +}; + +export const useBluetoothAudioStore = create((set, get) => ({ + ...INITIAL_STATE, // Bluetooth state actions setBluetoothState: (state) => set({ bluetoothState: state }), diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index ecc8811b..bc7fa557 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -1,7 +1,9 @@ +import { RTCAudioSession } from '@livekit/react-native-webrtc'; import notifee, { AndroidForegroundServiceType, AndroidImportance } from '@notifee/react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; +import { Audio, InterruptionModeIOS } from 'expo-av'; import { Room, RoomEvent } from 'livekit-client'; -import { Platform } from 'react-native'; +import { PermissionsAndroid, Platform } from 'react-native'; import { create } from 'zustand'; import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../api/voice'; @@ -12,12 +14,71 @@ 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 +export const applyAudioRouting = async (deviceType: 'bluetooth' | 'speaker' | 'earpiece' | 'default') => { + try { + if (Platform.OS === 'android') { + logger.info({ + message: 'Applying Android audio routing', + context: { deviceType }, + }); + + // On Android, we use RTCAudioSession for precise control + // First, ensure the audio session is configured correctly + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + staysActiveInBackground: true, + playsInSilentModeIOS: true, + shouldDuckAndroid: false, + // For speaker, we want false (speaker). For others, simple routing. + playThroughEarpieceAndroid: deviceType !== 'speaker', + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, + }); + + // Use RTCAudioSession to force route selection for WebRTC (Not available on Android in this package) + // We rely on Audio.setAudioModeAsync and system behavior. + /* + const RTCAudioSessionAny = RTCAudioSession as any; + if (RTCAudioSessionAny.getAudioDevices && RTCAudioSessionAny.selectAudioDevice) { + // ... (Logic removed as it's iOS only) + } else { + logger.info({ + message: 'RTCAudioSession Android methods not available (Relying on system routing)', + }); + } + */ + logger.info({ + message: 'Android audio routing applied via Audio.setAudioModeAsync', + }); + } else { + // iOS handling (Expo AV is usually sufficient, but CallKeep handles the session) + // Just ensure the mode is correct + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + staysActiveInBackground: true, + playsInSilentModeIOS: true, + shouldDuckAndroid: false, + playThroughEarpieceAndroid: true, + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, + }); + } + } catch (error) { + logger.error({ + message: 'Failed to apply audio routing', + context: { error }, + }); + } +}; + // Helper function to setup audio routing based on selected devices const setupAudioRouting = async (room: Room): Promise => { try { const bluetoothStore = useBluetoothAudioStore.getState(); const { selectedAudioDevices, connectedDevice } = bluetoothStore; + // Determine target device type + let targetType: 'bluetooth' | 'speaker' | 'earpiece' = 'earpiece'; + // If we have a connected Bluetooth device, prioritize it if (connectedDevice && connectedDevice.hasAudioCapability) { logger.info({ @@ -39,18 +100,21 @@ const setupAudioRouting = async (room: Room): Promise => { bluetoothStore.setSelectedMicrophone(bluetoothMicrophone); bluetoothStore.setSelectedSpeaker(bluetoothSpeaker); - // Note: Actual audio routing would be implemented via native modules - // This is a placeholder for the audio routing logic - logger.debug({ - message: 'Audio routing configured for Bluetooth device', - }); + targetType = 'bluetooth'; } else { // Use default audio devices (selected devices or default) logger.debug({ message: 'Using default audio devices', context: { selectedAudioDevices }, }); + + if (selectedAudioDevices.speaker?.type === 'speaker') { + targetType = 'speaker'; + } } + + // Apply the routing + await applyAudioRouting(targetType); } catch (error) { logger.error({ message: 'Failed to setup audio routing', @@ -141,9 +205,21 @@ export const useLiveKitStore = create((set, get) => ({ // and don't require runtime permission requests. They are automatically granted // when the app is installed if declared in AndroidManifest.xml if (Platform.OS === 'android') { - logger.debug({ - message: 'Foreground service permissions are handled at manifest level', - }); + // Request phone state/numbers permissions for CallKeep (required for Android 11+) + try { + // We need these permissions to use the ConnectionService (CallKeep) properly without crashing + const granted = await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS, PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]); + + logger.debug({ + message: 'Android Phone permissions requested', + context: { result: granted }, + }); + } catch (err) { + logger.warn({ + message: 'Failed to request Android phone permissions', + context: { error: err }, + }); + } } } } catch (error) { @@ -203,7 +279,27 @@ 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); + logger.info({ + message: 'Media button service initialized for PTT support', + context: { settings: mediaButtonPTTSettings }, + }); + } catch (mediaButtonError) { + logger.warn({ + message: 'Failed to initialize media button service - AirPods/earbuds PTT may not work', + context: { error: mediaButtonError }, + }); + // Don't fail the connection if media button service fails + } + // Setup audio routing based on selected devices + // This may change audio modes/focus, so it comes after media button init await setupAudioRouting(room); await audioService.playConnectToAudioRoomSound(); @@ -242,13 +338,13 @@ export const useLiveKitStore = create((set, get) => ({ // The call will still work but may be killed in background } - // Start CallKeep call for iOS background audio support - if (Platform.OS === 'ios') { + // Start CallKeep call for iOS and Android background audio support + if (Platform.OS === 'ios' || Platform.OS === 'android') { try { const callUUID = await callKeepService.startCall(roomInfo.Name || 'Voice Channel'); logger.info({ - message: 'CallKeep call started for iOS background support', - context: { callUUID, roomName: roomInfo.Name }, + message: 'CallKeep call started for background audio support', + context: { callUUID, roomName: roomInfo.Name, platform: Platform.OS }, }); } catch (callKeepError) { logger.warn({ @@ -259,24 +355,6 @@ export const useLiveKitStore = create((set, get) => ({ } } - // Initialize media button service for AirPods/earbuds PTT support - try { - await mediaButtonService.initialize(); - // Apply stored settings from the Bluetooth audio store - const { mediaButtonPTTSettings } = useBluetoothAudioStore.getState(); - mediaButtonService.updateSettings(mediaButtonPTTSettings); - logger.info({ - message: 'Media button service initialized for PTT support', - context: { settings: mediaButtonPTTSettings }, - }); - } catch (mediaButtonError) { - logger.warn({ - message: 'Failed to initialize media button service - AirPods/earbuds PTT may not work', - context: { error: mediaButtonError }, - }); - // Don't fail the connection if media button service fails - } - set({ currentRoom: room, currentRoomInfo: roomInfo, @@ -298,8 +376,8 @@ export const useLiveKitStore = create((set, get) => ({ await currentRoom.disconnect(); await audioService.playDisconnectedFromAudioRoomSound(); - // End CallKeep call on iOS - if (Platform.OS === 'ios') { + // End CallKeep call on iOS and Android + if (Platform.OS === 'ios' || Platform.OS === 'android') { try { await callKeepService.endCall(); logger.debug({ diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 089f6312..48074b31 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -257,45 +257,48 @@ const useAuthStore = create()( return; } - if (state && state.refreshToken && state.status === 'signedIn') { - // We have a stored refresh token and were previously signed in - // Schedule an immediate token refresh to ensure we have a valid access token - logger.info({ - message: 'Auth state rehydrated from storage, scheduling token refresh', - context: { hasAccessToken: !!state.accessToken, hasRefreshToken: !!state.refreshToken }, - }); + // Defer execution to ensure useAuthStore is fully initialized + setTimeout(() => { + if (state && state.refreshToken && state.status === 'signedIn') { + // We have a stored refresh token and were previously signed in + // Schedule an immediate token refresh to ensure we have a valid access token + logger.info({ + message: 'Auth state rehydrated from storage, scheduling token refresh', + context: { hasAccessToken: !!state.accessToken, hasRefreshToken: !!state.refreshToken }, + }); - // Clear any existing refresh timer before scheduling a new one - const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; - if (existingTimeoutId !== null) { - clearTimeout(existingTimeoutId); - } - // Use a small delay to allow the app to fully initialize - const timeoutId = setTimeout(() => { - useAuthStore.getState().refreshAccessToken(); - }, 2000); - useAuthStore.setState({ refreshTimeoutId: timeoutId }); - } else if (state && state.refreshToken && state.status !== 'signedIn') { - // We have a refresh token but status is not signedIn (maybe was idle/error) - // Try to refresh and restore the session - logger.info({ - message: 'Found refresh token in storage with non-signedIn status, attempting to restore session', - context: { status: state.status }, - }); + // Clear any existing refresh timer before scheduling a new one + const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; + if (existingTimeoutId !== null) { + clearTimeout(existingTimeoutId); + } + // Use a small delay to allow the app to fully initialize + const timeoutId = setTimeout(() => { + useAuthStore.getState().refreshAccessToken(); + }, 2000); + useAuthStore.setState({ refreshTimeoutId: timeoutId }); + } else if (state && state.refreshToken && state.status !== 'signedIn') { + // We have a refresh token but status is not signedIn (maybe was idle/error) + // Try to refresh and restore the session + logger.info({ + message: 'Found refresh token in storage with non-signedIn status, attempting to restore session', + context: { status: state.status }, + }); - // Clear any existing refresh timer before scheduling a new one - const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; - if (existingTimeoutId !== null) { - clearTimeout(existingTimeoutId); - } - // Set status to loading while we try to refresh - useAuthStore.setState({ status: 'loading' }); + // Clear any existing refresh timer before scheduling a new one + const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; + if (existingTimeoutId !== null) { + clearTimeout(existingTimeoutId); + } + // Set status to loading while we try to refresh + useAuthStore.setState({ status: 'loading' }); - const timeoutId = setTimeout(() => { - useAuthStore.getState().refreshAccessToken(); - }, 2000); - useAuthStore.setState({ refreshTimeoutId: timeoutId }); - } + const timeoutId = setTimeout(() => { + useAuthStore.getState().refreshAccessToken(); + }, 2000); + useAuthStore.setState({ refreshTimeoutId: timeoutId }); + } + }, 0); }; }, } diff --git a/src/translations/ar.json b/src/translations/ar.json index c3ed0882..622091a4 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -47,6 +47,7 @@ "connected": "متصل", "connectionError": "خطأ في الاتصال", "current_selection": "الاختيار الحالي", + "device_disconnected": "Device disconnected", "disconnect": "قطع الاتصال", "doublePress": "ضغط مزدوج ", "liveKitActive": "LiveKit نشط", diff --git a/src/translations/en.json b/src/translations/en.json index 748cb6b7..73dc4761 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -47,6 +47,7 @@ "connected": "Connected", "connectionError": "Connection Error", "current_selection": "Current Selection", + "device_disconnected": "Device disconnected", "disconnect": "Disconnect", "doublePress": "Double ", "liveKitActive": "LiveKit Active", diff --git a/src/translations/es.json b/src/translations/es.json index 10ef2527..8b6f9746 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -47,6 +47,7 @@ "connected": "Conectado", "connectionError": "Error de Conexión", "current_selection": "Selección Actual", + "device_disconnected": "Dispositivo desconectado", "disconnect": "Desconectar", "doublePress": "Doble ", "liveKitActive": "LiveKit Activo", diff --git a/yarn.lock b/yarn.lock index a2c5bfd7..6c0289ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6113,7 +6113,7 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" -asap@~2.0.3, asap@~2.0.6: +asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== @@ -7259,10 +7259,10 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" -countly-sdk-react-native-bridge@^25.4.0: - version "25.4.0" - resolved "https://registry.yarnpkg.com/countly-sdk-react-native-bridge/-/countly-sdk-react-native-bridge-25.4.0.tgz#dd04086142becf41b4312c8fe361db87b235e04d" - integrity sha512-MIkQtb5UfWW7FhC7pB6luudlfdTk0YA42YCKtnAwH+0gcm4jkMMuqq0HLytqFWki9fcCzfyatz+HGIu5s5mKvA== +countly-sdk-react-native-bridge@25.4.1: + version "25.4.1" + resolved "https://registry.yarnpkg.com/countly-sdk-react-native-bridge/-/countly-sdk-react-native-bridge-25.4.1.tgz#068485670d6d0920e3993171d1c2f5550d5128c2" + integrity sha512-6rwQ2TIfh+F1zKsTpat5XtW8v/GQb5SV4Q1Ly0SDpyfsvLfFLh72DaEHzdjRnP5qOMWnG38AICPrz9Fm3DzY/w== crc@^3.8.0: version "3.8.0" @@ -13609,14 +13609,7 @@ promise.allsettled@^1.0.5: get-intrinsic "^1.2.1" iterate-value "^1.0.2" -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== - dependencies: - asap "~2.0.3" - -promise@^8.3.0: +promise@8.3.0, promise@^7.1.1, promise@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a" integrity sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==