Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ 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.READ_PHONE_NUMBERS',
'android.permission.MANAGE_OWN_CALLS',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
},
web: {
Expand Down
2 changes: 1 addition & 1 deletion expo-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/// <reference types="expo/types" />

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

Check warning on line 3 in expo-env.d.ts

View workflow job for this annotation

GitHub Actions / test

Insert `⏎`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add missing newline at end of file.

The CI pipeline is failing because Prettier requires a trailing newline. Add a newline after line 3.

🧰 Tools
🪛 GitHub Actions: React Native CI/CD

[warning] 3-3: prettier/prettier: Insert ⏎

🪛 GitHub Check: test

[warning] 3-3:
Insert

🤖 Prompt for AI Agents
In `@expo-env.d.ts` at line 3, The file expo-env.d.ts is missing a trailing
newline which breaks Prettier in CI; update expo-env.d.ts (the file containing
the comment "// NOTE: This file should not be edited and should be in your git
ignore") by adding a single newline character at the end of the file (ensure the
file ends with a blank line / newline) and commit the change so Prettier/CI
accepts the file.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -167,6 +168,7 @@
"react-native-restart": "0.0.27",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-sound": "^0.13.0",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all references to react-native-sound
rg "react-native-sound" -g '!node_modules'

Repository: Resgrid/Unit

Length of output: 1148


🏁 Script executed:

#!/bin/bash
# Check for expo config files and plugins
find . -maxdepth 3 -type f \( -name "app.json" -o -name "eas.json" -o -name "app.config.*" \) | head -20

Repository: Resgrid/Unit

Length of output: 82


🏁 Script executed:

#!/bin/bash
# Check if expo-av is already in package.json
grep -A 2 -B 2 '"expo-av"' package.json

Repository: Resgrid/Unit

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Search for audio-related code
rg "audio\." -g '!node_modules' --type ts --type js | head -30

Repository: Resgrid/Unit

Length of output: 3404


🌐 Web query:

react-native-sound expo config plugin compatibility 2025

💡 Result:

  • react-native-sound is a native module, so it won’t work in Expo Go. To use it in an Expo app you need a custom development build / Dev Client and to generate native projects via CNG (expo prebuild) / EAS Build. (docs.expo.dev)
  • Config plugin status (2025): there doesn’t appear to be an official or commonly used community Expo config plugin specifically for react-native-sound (it’s not among the community-maintained plugin repo’s catalog). In many cases, a library needs no config plugin at all—you just install it and rebuild—unless it requires extra native edits (Info.plist/AndroidManifest/AppDelegate, etc.). (github.com)
  • Practical recommendation for Expo apps: if you primarily need audio playback/recording and want “Expo-native” support (including a built-in config plugin), prefer expo-audio (or other Expo audio libraries) instead of react-native-sound. (docs.expo.dev)

Citations:


Consider consolidating to expo-audio instead of adding react-native-sound.

react-native-sound requires native code and has no official Expo config plugin. While your project's Expo prebuild setup can handle it, you already have expo-audio (v0.4.9) as a dependency—which is purpose-built for Expo's managed workflow and fully supported by Expo SDK.

Evaluate whether expo-audio can cover your audio playback needs for the audio service instead of adding another native audio library. This will reduce native dependencies and align better with Expo's managed workflow approach.

🤖 Prompt for AI Agents
In `@package.json` at line 171, The PR added the native dependency
"react-native-sound" which duplicates audio functionality already provided by
"expo-audio"; remove "react-native-sound" from package.json and refactor your
audio service to use the existing expo-audio APIs instead of native
react-native-sound calls. Locate imports and usage in files referencing
"react-native-sound" (e.g., AudioService, any play/pause/stop helper functions)
and replace them with expo-audio equivalents (update imports from "expo-audio"
and adapt to its loadAsync/playAsync/unloadAsync patterns), remove any
native-only setup/config, and update README/dependency notes accordingly so the
project uses only expo-audio. Ensure package.json and Metro/bundler config no
longer reference react-native-sound and run a build to verify no native modules
remain.

"react-native-svg": "15.11.2",
"react-native-web": "^0.20.0",
"react-native-webview": "~13.13.1",
Expand Down Expand Up @@ -258,6 +260,7 @@
"initVersion": "7.0.4"
},
"resolutions": {
"form-data": "4.0.4"
"form-data": "4.0.4",
"promise": "8.3.0"
}
}
70 changes: 70 additions & 0 deletions src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <View testID="mock-actionsheet">{children}</View> : null,
ActionsheetBackdrop: () => null,
ActionsheetContent: ({ children }: any) => <View testID="mock-actionsheet-content">{children}</View>,
ActionsheetDragIndicatorWrapper: ({ children }: any) => <View>{children}</View>,
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';
Expand Down Expand Up @@ -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(<LiveKitBottomSheet />);

// 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(<LiveKitBottomSheet />);

// 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', () => {
Expand Down
48 changes: 42 additions & 6 deletions src/components/livekit/livekit-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +32,7 @@ export const LiveKitBottomSheet = () => {
const { trackEvent } = useAnalytics();

const [currentView, setCurrentView] = useState<BottomSheetView>(BottomSheetView.ROOM_SELECT);
const [previousView, setPreviousView] = useState<BottomSheetView | null>(null);
const [isMuted, setIsMuted] = useState(true); // Default to muted
const [permissionsRequested, setPermissionsRequested] = useState(false);

Expand Down Expand Up @@ -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]);
Comment on lines +150 to +176
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace console.log/error with logger for consistency.

The codebase uses logger from @/lib/logging throughout. Lines 157 and 171 use console.log/console.error instead.

Additionally, this audio routing logic appears duplicated with setupAudioRouting in src/stores/app/livekit-store.ts. Consider whether this effect is necessary given that setupAudioRouting is called during connectToRoom.

🔧 Proposed fix for logging
-        console.log('Updating audio routing for:', speaker.type);
+        logger.debug({
+          message: 'Updating audio routing for speaker',
+          context: { speakerType: speaker.type },
+        });
...
-        console.error('Failed to update audio routing:', error);
+        logger.error({
+          message: 'Failed to update audio routing',
+          context: { error },
+        });

,

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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]);
// Audio Routing Logic
useEffect(() => {
const updateAudioRouting = async () => {
if (!selectedAudioDevices.speaker) return;
try {
const speaker = selectedAudioDevices.speaker;
logger.debug({
message: 'Updating audio routing for speaker',
context: { speakerType: 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) {
logger.error({
message: 'Failed to update audio routing',
context: { error },
});
}
};
updateAudioRouting();
}, [selectedAudioDevices.speaker]);
🤖 Prompt for AI Agents
In `@src/components/livekit/livekit-bottom-sheet.tsx` around lines 150 - 176,
Replace console.log and console.error in the updateAudioRouting useEffect with
the shared logger from "@/lib/logging" (use logger.info or logger.debug for the
'Updating audio routing for:' message and logger.error for failures) and keep
references to selectedAudioDevices.speaker and applyAudioRouting; then
evaluate/remove this entire useEffect if it duplicates setupAudioRouting from
livekit-store (called during connectToRoom) to avoid two competing routing
flows—if you remove it, ensure connectToRoom → setupAudioRouting covers all
speaker type branches formerly handled by updateAudioRouting.


const handleRoomSelect = useCallback(
(room: DepartmentVoiceChannelResultData) => {
connectToRoom(room, room.Token);
Expand Down Expand Up @@ -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 = () => (
<View style={styles.content}>
Expand Down Expand Up @@ -303,7 +338,7 @@ export const LiveKitBottomSheet = () => {
<View className="w-full p-4">
<HStack className="mb-4 items-center justify-between">
<Text className="text-xl font-bold">{t('livekit.title')}</Text>
{currentView === BottomSheetView.CONNECTED && (
{currentView !== BottomSheetView.AUDIO_SETTINGS && (
<TouchableOpacity onPress={handleShowAudioSettings} testID="header-audio-settings-button">
<Headphones size={20} color="#6B7280" />
</TouchableOpacity>
Expand All @@ -323,7 +358,7 @@ const styles = StyleSheet.create({
content: {
flex: 1,
width: '100%',
paddingHorizontal: 16,
paddingHorizontal: 8,
},
roomList: {
flex: 1,
Expand All @@ -334,6 +369,7 @@ const styles = StyleSheet.create({
controls: {
flexDirection: 'row',
justifyContent: 'space-around',
width: '100%',
marginTop: 16,
},
controlButton: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -247,9 +247,8 @@ describe('AudioDeviceSelection', () => {
render(<AudioDeviceSelection />);

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();
});
});

Expand All @@ -275,9 +274,8 @@ describe('AudioDeviceSelection', () => {

render(<AudioDeviceSelection />);

// 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();
});
});
});
4 changes: 2 additions & 2 deletions src/components/settings/audio-device-selection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ export const AudioDeviceSelection: React.FC<AudioDeviceSelectionProps> = ({ 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 (
<ScrollView className="flex-1">
Expand Down
Loading
Loading