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==