diff --git a/.eslintrc.js b/.eslintrc.js
index 96a6bed9..a69187ca 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -44,6 +44,7 @@ module.exports = {
files: ['src/translations/*.json'],
extends: ['plugin:i18n-json/recommended'],
rules: {
+ '@typescript-eslint/consistent-type-imports': 'off',
'i18n-json/valid-message-syntax': [
2,
{
diff --git a/package.json b/package.json
index c1e93dcc..8f2f029b 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,7 @@
"lint": "eslint src --ext .ts,.tsx --cache --cache-location node_modules/.cache/eslint",
"type-check": "tsc --noemit",
"lint:translations": "eslint ./src/translations/ --fix --ext .json ",
- "test": "jest --coverage=true --coverageReporters=cobertura",
+ "test": "jest --runInBand --coverage=true --coverageReporters=cobertura",
"check-all": "yarn run lint && yarn run type-check && yarn run lint:translations",
"test:ci": "yarn run test --coverage",
"test:watch": "yarn run test --watch",
@@ -174,8 +174,7 @@
"react-query-kit": "~3.3.0",
"tailwind-variants": "~0.2.1",
"zod": "~3.23.8",
- "zustand": "~4.5.5",
- "babel-plugin-transform-import-meta": "^2.3.3"
+ "zustand": "~4.5.5"
},
"devDependencies": {
"@babel/core": "~7.26.0",
@@ -191,8 +190,9 @@
"@types/mapbox-gl": "3.4.1",
"@types/react": "~19.0.10",
"@types/react-native-base64": "~0.2.2",
- "@typescript-eslint/eslint-plugin": "~5.62.0",
- "@typescript-eslint/parser": "~5.62.0",
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
+ "@typescript-eslint/parser": "^8.0.0",
+ "babel-plugin-transform-import-meta": "^2.3.3",
"babel-jest": "~30.0.0",
"concurrently": "9.2.1",
"cross-env": "~7.0.3",
@@ -201,7 +201,7 @@
"electron-builder": "26.4.0",
"electron-squirrel-startup": "^1.0.1",
"eslint": "~8.57.0",
- "eslint-config-expo": "~7.1.2",
+ "eslint-config-expo": "~9.2.0",
"eslint-config-prettier": "~9.1.0",
"eslint-import-resolver-typescript": "~3.6.3",
"eslint-plugin-i18n-json": "~4.0.0",
@@ -226,7 +226,7 @@
"tailwindcss": "3.4.4",
"ts-jest": "~29.1.2",
"ts-node": "~10.9.2",
- "typescript": "~5.8.3",
+ "typescript": "5.8.x",
"wait-on": "9.0.3"
},
"repository": {
diff --git a/plugins/withInCallAudioModule.js b/plugins/withInCallAudioModule.js
index 5add14d4..2deb3627 100644
--- a/plugins/withInCallAudioModule.js
+++ b/plugins/withInCallAudioModule.js
@@ -12,7 +12,6 @@ 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.*
@@ -101,6 +100,62 @@ class InCallAudioModule(reactContext: ReactApplicationContext) : ReactContextBas
}
}
+ @ReactMethod
+ fun setAudioRoute(route: String, promise: Promise) {
+ try {
+ val audioManager = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
+ if (audioManager == null) {
+ promise.reject("AUDIO_MANAGER_UNAVAILABLE", "AudioManager is not available")
+ return
+ }
+
+ val normalizedRoute = route.lowercase()
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
+
+ when (normalizedRoute) {
+ "bluetooth" -> {
+ audioManager.isSpeakerphoneOn = false
+ if (!audioManager.isBluetoothScoAvailableOffCall) {
+ audioManager.isBluetoothScoOn = false
+ promise.reject("BLUETOOTH_SCO_UNAVAILABLE", "Bluetooth SCO is not available off call")
+ return
+ }
+
+ audioManager.startBluetoothSco()
+ audioManager.isBluetoothScoOn = true
+
+ if (!audioManager.isBluetoothScoOn) {
+ promise.reject("BLUETOOTH_SCO_START_FAILED", "Failed to start Bluetooth SCO")
+ return
+ }
+ }
+
+ "speaker" -> {
+ audioManager.stopBluetoothSco()
+ audioManager.isBluetoothScoOn = false
+ audioManager.isSpeakerphoneOn = true
+ }
+
+ "earpiece", "default" -> {
+ audioManager.stopBluetoothSco()
+ audioManager.isBluetoothScoOn = false
+ audioManager.isSpeakerphoneOn = false
+ }
+
+ else -> {
+ promise.reject("INVALID_AUDIO_ROUTE", "Unsupported audio route: $route")
+ return
+ }
+ }
+
+ Log.d(TAG, "Audio route set to: $normalizedRoute")
+ promise.resolve(true)
+ } catch (error: Exception) {
+ Log.e(TAG, "Failed to set audio route: $route", error)
+ promise.reject("SET_AUDIO_ROUTE_FAILED", error.message, error)
+ }
+ }
+
@ReactMethod
fun cleanup() {
soundPool?.release()
diff --git a/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx b/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx
index 575387fe..e76dbd05 100644
--- a/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx
+++ b/src/components/calls/__tests__/call-detail-menu-analytics.test.tsx
@@ -6,6 +6,14 @@ import { useAnalytics } from '@/hooks/use-analytics';
// Mock dependencies
jest.mock('@/hooks/use-analytics');
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
diff --git a/src/components/calls/__tests__/call-detail-menu-integration.test.tsx b/src/components/calls/__tests__/call-detail-menu-integration.test.tsx
index 1f1d347b..0adbf937 100644
--- a/src/components/calls/__tests__/call-detail-menu-integration.test.tsx
+++ b/src/components/calls/__tests__/call-detail-menu-integration.test.tsx
@@ -65,6 +65,15 @@ jest.mock('@/components/ui/', () => ({
},
}));
+jest.mock('@/lib/logging', () => ({
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
describe('Call Detail Menu Integration Test', () => {
const mockOnEditCall = jest.fn();
const mockOnCloseCall = jest.fn();
diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx
index 88e1fdc8..33a37adb 100644
--- a/src/components/livekit/livekit-bottom-sheet.tsx
+++ b/src/components/livekit/livekit-bottom-sheet.tsx
@@ -6,7 +6,6 @@ import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useAnalytics } from '@/hooks/use-analytics';
import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData';
-import { audioService } from '@/services/audio.service';
import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
import { Card } from '../../components/ui/card';
@@ -35,6 +34,9 @@ export const LiveKitBottomSheet = () => {
const isConnected = useLiveKitStore((s) => s.isConnected);
const isConnecting = useLiveKitStore((s) => s.isConnecting);
const isTalking = useLiveKitStore((s) => s.isTalking);
+ const isMicrophoneEnabled = useLiveKitStore((s) => s.isMicrophoneEnabled);
+ const setMicrophoneEnabled = useLiveKitStore((s) => s.setMicrophoneEnabled);
+ const lastLocalMuteChangeTimestamp = useLiveKitStore((s) => s.lastLocalMuteChangeTimestamp);
const selectedAudioDevices = useBluetoothAudioStore((s) => s.selectedAudioDevices);
const { colorScheme } = useColorScheme();
@@ -77,11 +79,8 @@ export const LiveKitBottomSheet = () => {
// Sync mute state with LiveKit room
useEffect(() => {
- if (currentRoom?.localParticipant) {
- const micEnabled = currentRoom.localParticipant.isMicrophoneEnabled;
- setIsMuted(!micEnabled);
- }
- }, [currentRoom?.localParticipant, currentRoom?.localParticipant?.isMicrophoneEnabled]);
+ setIsMuted(!isMicrophoneEnabled);
+ }, [isMicrophoneEnabled, lastLocalMuteChangeTimestamp]);
useEffect(() => {
// If we're showing the sheet, make sure we have the latest rooms
@@ -135,25 +134,11 @@ export const LiveKitBottomSheet = () => {
);
const handleMuteToggle = useCallback(async () => {
- if (currentRoom?.localParticipant) {
- const newMicEnabled = isMuted; // If currently muted, enable mic
- try {
- await currentRoom.localParticipant.setMicrophoneEnabled(newMicEnabled);
- setIsMuted(!newMicEnabled);
-
- // Play appropriate sound based on mute state
- if (newMicEnabled) {
- // Mic is being unmuted
- await audioService.playStartTransmittingSound();
- } else {
- // Mic is being muted
- await audioService.playStopTransmittingSound();
- }
- } catch (error) {
- console.error('Failed to toggle microphone:', error);
- }
+ if (isConnected) {
+ const newMicEnabled = isMuted;
+ await setMicrophoneEnabled(newMicEnabled);
}
- }, [currentRoom, isMuted]);
+ }, [isConnected, isMuted, setMicrophoneEnabled]);
const handleDisconnect = useCallback(() => {
disconnectFromRoom();
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 b3248a46..a91f01c2 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
@@ -338,7 +338,7 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {
render();
- expect(screen.getByText('bluetooth.bluetooth_disabled')).toBeTruthy();
+ expect(screen.getByText('bluetooth.poweredOff')).toBeTruthy();
});
it('displays connection errors', () => {
diff --git a/src/components/settings/audio-device-selection.tsx b/src/components/settings/audio-device-selection.tsx
index 043c18f6..535aa85f 100644
--- a/src/components/settings/audio-device-selection.tsx
+++ b/src/components/settings/audio-device-selection.tsx
@@ -48,6 +48,25 @@ export const AudioDeviceSelection: React.FC = ({ show
}
};
+ const getDeviceDisplayName = (device: AudioDeviceInfo) => {
+ const normalizedId = device.id.toLowerCase();
+ const normalizedName = device.name.toLowerCase();
+
+ if (normalizedId === 'system-audio' || normalizedName === 'system audio' || normalizedName === 'system-audio' || normalizedName === 'system_audio') {
+ return t('settings.audio_device_selection.system_audio');
+ }
+
+ if (normalizedId === 'default-mic' || normalizedName === 'default microphone') {
+ return t('settings.audio_device_selection.default_microphone');
+ }
+
+ if (normalizedId === 'default-speaker' || normalizedName === 'default speaker') {
+ return t('settings.audio_device_selection.default_speaker');
+ }
+
+ return device.name;
+ };
+
const renderDeviceItem = (device: AudioDeviceInfo, isSelected: boolean, onSelect: () => void, deviceType: 'microphone' | 'speaker') => {
const deviceTypeLabel = getDeviceTypeLabel(device.type);
const unavailableText = !device.isAvailable ? ` (${t('settings.audio_device_selection.unavailable')})` : '';
@@ -59,7 +78,7 @@ export const AudioDeviceSelection: React.FC = ({ show
{renderDeviceIcon(device)}
- {device.name}
+ {getDeviceDisplayName(device)}
{deviceTypeLabel}
{unavailableText}
@@ -95,12 +114,14 @@ export const AudioDeviceSelection: React.FC = ({ show
{t('settings.audio_device_selection.microphone')}:
- {selectedAudioDevices.microphone?.name || t('settings.audio_device_selection.none_selected')}
+
+ {selectedAudioDevices.microphone ? getDeviceDisplayName(selectedAudioDevices.microphone) : t('settings.audio_device_selection.none_selected')}
+
{t('settings.audio_device_selection.speaker')}:
- {selectedAudioDevices.speaker?.name || t('settings.audio_device_selection.none_selected')}
+ {selectedAudioDevices.speaker ? getDeviceDisplayName(selectedAudioDevices.speaker) : t('settings.audio_device_selection.none_selected')}
diff --git a/src/components/settings/bluetooth-device-item.tsx b/src/components/settings/bluetooth-device-item.tsx
index e1aee960..6b0d5aa7 100644
--- a/src/components/settings/bluetooth-device-item.tsx
+++ b/src/components/settings/bluetooth-device-item.tsx
@@ -1,4 +1,4 @@
-import { BluetoothIcon, ChevronRightIcon } from 'lucide-react-native';
+import { ChevronRightIcon } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,6 +20,9 @@ export const BluetoothDeviceItem = () => {
const deviceDisplayName = React.useMemo(() => {
if (preferredDevice) {
+ if (preferredDevice.id === 'system-audio') {
+ return t('bluetooth.system_audio');
+ }
return preferredDevice.name;
}
return t('bluetooth.no_device_selected');
diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
index 84f9f8e6..0c25fa68 100644
--- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
+++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
@@ -37,21 +37,13 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
const connectionError = useBluetoothAudioStore((s) => s.connectionError);
const [hasScanned, setHasScanned] = useState(false);
const [connectingDeviceId, setConnectingDeviceId] = useState(null);
-
- // Start scanning when sheet opens
- useEffect(() => {
- if (isOpen && !hasScanned) {
- startScan();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isOpen, hasScanned]);
+ const preferredDeviceDisplayName = preferredDevice?.id === 'system-audio' ? t('bluetooth.system_audio') : preferredDevice?.name || t('bluetooth.unknown_device');
const startScan = React.useCallback(async () => {
try {
setHasScanned(true);
await bluetoothAudioService.startScanning(10000); // 10 second scan
} catch (error) {
- setHasScanned(false); // Reset scan state on error
logger.error({
message: 'Failed to start Bluetooth scan',
context: { error },
@@ -61,6 +53,13 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
}
}, [t]);
+ // Start scanning when sheet opens
+ useEffect(() => {
+ if (isOpen && !hasScanned) {
+ startScan();
+ }
+ }, [isOpen, hasScanned, startScan]);
+
const handleDeviceSelect = React.useCallback(
async (device: BluetoothAudioDevice) => {
try {
@@ -222,7 +221,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
}, [isScanning, hasScanned, startScan, t]);
return (
-
+
{t('bluetooth.select_device')}
@@ -232,7 +231,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
{t('bluetooth.current_selection')}
- {preferredDevice.name}
+ {preferredDeviceDisplayName}