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}