Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
57 changes: 56 additions & 1 deletion plugins/withInCallAudioModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
33 changes: 9 additions & 24 deletions src/components/livekit/livekit-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe('BluetoothDeviceSelectionBottomSheet', () => {

render(<BluetoothDeviceSelectionBottomSheet {...mockProps} />);

expect(screen.getByText('bluetooth.bluetooth_disabled')).toBeTruthy();
expect(screen.getByText('bluetooth.poweredOff')).toBeTruthy();
});

it('displays connection errors', () => {
Expand Down
27 changes: 24 additions & 3 deletions src/components/settings/audio-device-selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ export const AudioDeviceSelection: React.FC<AudioDeviceSelectionProps> = ({ 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')})` : '';
Expand All @@ -59,7 +78,7 @@ export const AudioDeviceSelection: React.FC<AudioDeviceSelectionProps> = ({ show
<HStack className="flex-1 items-center" space="md">
{renderDeviceIcon(device)}
<VStack className="flex-1">
<Text className={`font-medium ${isSelected ? 'text-blue-900 dark:text-blue-100' : 'text-gray-900 dark:text-gray-100'}`}>{device.name}</Text>
<Text className={`font-medium ${isSelected ? 'text-blue-900 dark:text-blue-100' : 'text-gray-900 dark:text-gray-100'}`}>{getDeviceDisplayName(device)}</Text>
<Text className={`text-sm ${isSelected ? 'text-blue-700 dark:text-blue-300' : 'text-gray-500 dark:text-gray-400'}`}>
{deviceTypeLabel}
{unavailableText}
Expand Down Expand Up @@ -95,12 +114,14 @@ export const AudioDeviceSelection: React.FC<AudioDeviceSelectionProps> = ({ show

<HStack className="items-center justify-between">
<Text className="text-blue-800 dark:text-blue-200">{t('settings.audio_device_selection.microphone')}:</Text>
<Text className="font-medium text-blue-900 dark:text-blue-100">{selectedAudioDevices.microphone?.name || t('settings.audio_device_selection.none_selected')}</Text>
<Text className="font-medium text-blue-900 dark:text-blue-100">
{selectedAudioDevices.microphone ? getDeviceDisplayName(selectedAudioDevices.microphone) : t('settings.audio_device_selection.none_selected')}
</Text>
</HStack>

<HStack className="items-center justify-between">
<Text className="text-blue-800 dark:text-blue-200">{t('settings.audio_device_selection.speaker')}:</Text>
<Text className="font-medium text-blue-900 dark:text-blue-100">{selectedAudioDevices.speaker?.name || t('settings.audio_device_selection.none_selected')}</Text>
<Text className="font-medium text-blue-900 dark:text-blue-100">{selectedAudioDevices.speaker ? getDeviceDisplayName(selectedAudioDevices.speaker) : t('settings.audio_device_selection.none_selected')}</Text>
</HStack>
</VStack>
</Card>
Expand Down
5 changes: 4 additions & 1 deletion src/components/settings/bluetooth-device-item.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
Expand Down
33 changes: 14 additions & 19 deletions src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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 },
Expand All @@ -61,6 +53,13 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
}
}, [t]);

// Start scanning when sheet opens
useEffect(() => {
if (isOpen && !hasScanned) {
startScan();
}
}, [isOpen, hasScanned, startScan]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleDeviceSelect = React.useCallback(
async (device: BluetoothAudioDevice) => {
try {
Expand Down Expand Up @@ -222,7 +221,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
}, [isScanning, hasScanned, startScan, t]);

return (
<CustomBottomSheet isOpen={isOpen} onClose={onClose}>
<CustomBottomSheet isOpen={isOpen} onClose={onClose} snapPoints={[85]} minHeight="min-h-0">
<VStack className="flex-1 p-4">
<Heading className="mb-4 text-lg">{t('bluetooth.select_device')}</Heading>

Expand All @@ -232,7 +231,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
<HStack className="items-center justify-between">
<VStack>
<Text className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{t('bluetooth.current_selection')}</Text>
<Text className="text-sm text-neutral-600 dark:text-neutral-400">{preferredDevice.name}</Text>
<Text className="text-sm text-neutral-600 dark:text-neutral-400">{preferredDeviceDisplayName}</Text>
</VStack>
<Button onPress={handleClearSelection} size={isLandscape ? 'sm' : 'xs'} variant="outline">
<ButtonText className={isLandscape ? '' : 'text-2xs'}>{t('bluetooth.clear')}</ButtonText>
Expand All @@ -255,7 +254,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo

// Update preferred device manually here to ensure UI reflects it immediately
// preventing race conditions with store updates
await setPreferredDevice({ id: 'system-audio', name: 'System Audio' });
await setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') });

onClose();
} catch (error) {
Expand All @@ -277,8 +276,8 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
<HStack className="items-center">
<BluetoothIcon size={16} className="mr-2 text-primary-600" />
<VStack>
<Text className={`font-medium ${preferredDevice?.id === 'system-audio' ? 'text-primary-700 dark:text-primary-300' : 'text-neutral-900 dark:text-neutral-100'}`}>{t('bluetooth.systemAudio')}</Text>
<Text className="text-xs text-neutral-500">{t('bluetooth.systemAudioDescription')}</Text>
<Text className={`font-medium ${preferredDevice?.id === 'system-audio' ? 'text-primary-700 dark:text-primary-300' : 'text-neutral-900 dark:text-neutral-100'}`}>{t('bluetooth.system_audio')}</Text>
<Text className="text-xs text-neutral-500">{t('bluetooth.system_audio_description')}</Text>
</VStack>
</HStack>
{preferredDevice?.id === 'system-audio' && (
Expand Down Expand Up @@ -316,11 +315,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo
{bluetoothState !== State.PoweredOn && (
<Box className="mt-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900">
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
{bluetoothState === State.PoweredOff
? t('bluetooth.bluetooth_disabled')
: bluetoothState === State.Unauthorized
? t('bluetooth.bluetooth_unauthorized')
: t('bluetooth.bluetooth_not_ready', { state: bluetoothState })}
{bluetoothState === State.PoweredOff ? t('bluetooth.poweredOff') : bluetoothState === State.Unauthorized ? t('bluetooth.unauthorized') : t('bluetooth.bluetooth_not_ready', { state: bluetoothState })}
</Text>
</Box>
)}
Expand Down
5 changes: 3 additions & 2 deletions src/components/sidebar/call-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,9 @@ export const SidebarCallCard = () => {
console.error('Failed to handle call selection:', error);
});
}}
className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : ''
}`}
className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${
activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : ''
}`}
testID={`call-item-${call.CallId}`}
>
<HStack space="md" className="items-center justify-between">
Expand Down
Loading
Loading