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
29 changes: 29 additions & 0 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,35 @@ jobs:
- name: 📦 Install dependencies
run: yarn install --frozen-lockfile

- name: 📋 Update package.json Versions
shell: bash
run: |
# Install jq if needed
if ! command -v jq &> /dev/null; then
echo "Installing jq..."
if [ "$RUNNER_OS" = "Linux" ]; then
sudo apt-get update && sudo apt-get install -y jq
elif [ "$RUNNER_OS" = "macOS" ]; then
brew install jq
elif [ "$RUNNER_OS" = "Windows" ]; then
choco install jq -y
fi
fi

# Electron requires 3-octet version (MAJOR.MINOR.PATCH)
ELECTRON_VERSION="1.${{ github.run_number }}.0"
echo "Electron Version: ${ELECTRON_VERSION}"

if [ -f ./package.json ]; then
cp package.json package.json.bak
jq --arg version "$ELECTRON_VERSION" '.version = $version' package.json > package.json.tmp && mv package.json.tmp package.json
echo "Updated package.json version for Electron build"
cat package.json | grep '"version"'
else
echo "package.json not found"
exit 1
fi

- name: 🖥️ Build Electron App
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
5 changes: 4 additions & 1 deletion electron/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
const isDev = require('electron-is-dev');

// Use Electron's built-in app.isPackaged instead of electron-is-dev
// app.isPackaged is true when running from a packaged app, false during development
const isDev = !app.isPackaged;

function createWindow() {
const mainWindow = new BrowserWindow({
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
},
"build": {
"appId": "com.resgrid.dispatch",
"productName": "Dispatch",
"productName": "Resgrid Dispatch",
"publish": null,
"directories": {
"output": "dist/electron"
Expand Down
34 changes: 6 additions & 28 deletions src/components/dispatch-console/ptt-interface.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Headphones, Mic, MicOff, PhoneOff, Radio, Volume2, VolumeX, Wifi, WifiOff } from 'lucide-react-native';
import { Mic, MicOff, PhoneOff, Radio, Wifi, WifiOff } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -10,7 +10,6 @@
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { usePTT } from '@/hooks/use-ptt';
import { useAudioStreamStore } from '@/stores/app/audio-stream-store';

import { PTTChannelSelector } from './ptt-channel-selector';

Expand All @@ -19,18 +18,15 @@
onPTTPress?: () => void;
/** Optional callback when PTT press ends (for activity logging) */
onPTTRelease?: () => void;
/** Optional callback to open audio streams panel */
onOpenAudioStreams?: () => void;
/** External transmitting state (for activity logging only, actual state managed by hook) */
isTransmitting?: boolean;
/** Display name for current channel (fallback only, actual channel from hook) */
currentChannel?: string;
}

export const PTTInterface: React.FC<PTTInterfaceProps> = ({ onPTTPress, onPTTRelease, onOpenAudioStreams, isTransmitting: externalTransmitting = false, currentChannel: externalChannel = 'Main Channel' }) => {
export const PTTInterface: React.FC<PTTInterfaceProps> = ({ onPTTPress, onPTTRelease, isTransmitting: externalTransmitting = false, currentChannel: externalChannel = 'Disconnected' }) => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const { isPlaying, currentStream, setIsBottomSheetVisible } = useAudioStreamStore();

// Use refs to store callback functions to avoid re-creating onTransmittingChange
const onPTTPressRef = useRef(onPTTPress);
Expand Down Expand Up @@ -80,15 +76,7 @@

// Use actual PTT state or fallback to external props
const isTransmitting = isConnected ? pttTransmitting : externalTransmitting;
const displayChannel = pttChannel?.Name || externalChannel;

const handleOpenAudioStreams = useCallback(() => {
if (onOpenAudioStreams) {
onOpenAudioStreams();
} else {
setIsBottomSheetVisible(true);
}
}, [onOpenAudioStreams, setIsBottomSheetVisible]);
const displayChannel = isConnected ? (pttChannel?.Name || externalChannel) : (pttChannel?.Name || t('dispatch.disconnected'));

Check warning on line 79 in src/components/dispatch-console/ptt-interface.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `(pttChannel?.Name·||·externalChannel)·:·(pttChannel?.Name·||·t('dispatch.disconnected')` with `pttChannel?.Name·||·externalChannel·:·pttChannel?.Name·||·t('dispatch.disconnected'`

const handlePTTPress = useCallback(async () => {
if (!isConnected) {
Expand Down Expand Up @@ -133,9 +121,11 @@
}
selectChannel(channel);
setIsChannelSelectorOpen(false);
// Auto-connect to the selected channel
await connect(channel);
}
},
[availableChannels, isConnected, disconnect, selectChannel]
[availableChannels, isConnected, disconnect, selectChannel, connect]
);

const handleDisconnect = useCallback(async () => {
Expand Down Expand Up @@ -207,23 +197,11 @@
</Text>
</HStack>
</Pressable>
<Pressable onPress={handleOpenAudioStreams}>
<HStack className="items-center" space="xs">
<Icon as={isPlaying ? Volume2 : VolumeX} size="2xs" color={isPlaying ? '#3b82f6' : colorScheme === 'dark' ? '#6b7280' : '#9ca3af'} />
<Text className="text-xs text-gray-600 dark:text-gray-300" numberOfLines={1}>
{currentStream ? currentStream.Name : t('dispatch.no_stream')}
</Text>
</HStack>
</Pressable>
</VStack>
</HStack>

{/* Compact Controls */}
<HStack className="items-center" space="sm">

Check warning on line 204 in src/components/dispatch-console/ptt-interface.tsx

View workflow job for this annotation

GitHub Actions / test

Delete `⏎`
{/* Audio Streams Button */}
<Pressable onPress={handleOpenAudioStreams} style={StyleSheet.flatten([styles.compactControlButton, { backgroundColor: colorScheme === 'dark' ? '#374151' : '#e5e7eb' }])}>
<Icon as={Headphones} size="sm" color={colorScheme === 'dark' ? '#9ca3af' : '#6b7280'} />
</Pressable>

{/* Disconnect Button (only shown when connected) */}
{isConnected ? (
Expand Down
31 changes: 30 additions & 1 deletion src/hooks/use-ptt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppState, type AppStateStatus, Platform } from 'react-native';

import { logger } from '@/lib/logging';
import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData';
import { audioService } from '@/services/audio.service';
import { useLiveKitStore } from '@/stores/app/livekit-store';

// Platform-specific imports for iOS CallKeep
Expand Down Expand Up @@ -384,9 +385,27 @@ export function usePTT(options: UsePTTOptions = {}): UsePTTReturn {
setIsTransmitting(true);
setIsMuted(false);

// Play PTT start sound
try {
await audioService.playStartTransmittingSound();
} catch (sfxErr) {
logger.warn({
message: 'PTT: Failed to play start transmitting sound',
context: { error: sfxErr },
});
}

logger.debug({ message: 'PTT: Started transmitting' });
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to start transmitting';
let errorMsg = err instanceof Error ? err.message : 'Failed to start transmitting';

// Provide more helpful error messages for common issues
if (errorMsg.includes('Requested device not found') || errorMsg.includes('NotFoundError')) {
errorMsg = 'No microphone found. Please check your audio device settings.';
} else if (errorMsg.includes('NotAllowedError') || errorMsg.includes('Permission')) {
errorMsg = 'Microphone permission denied. Please allow microphone access.';
}

setError(errorMsg);
onErrorRef.current?.(errorMsg);
logger.error({
Expand All @@ -410,6 +429,16 @@ export function usePTT(options: UsePTTOptions = {}): UsePTTReturn {
await currentRoom.localParticipant.setMicrophoneEnabled(false);
setIsTransmitting(false);

// Play PTT stop sound
try {
await audioService.playStopTransmittingSound();
} catch (sfxErr) {
logger.warn({
message: 'PTT: Failed to play stop transmitting sound',
context: { error: sfxErr },
});
}

logger.debug({ message: 'PTT: Stopped transmitting' });
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to stop transmitting';
Expand Down
15 changes: 8 additions & 7 deletions src/stores/app/__tests__/livekit-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ jest.mock('../../../lib/logging', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
Expand All @@ -135,7 +136,7 @@ describe('LiveKit Store - Permission Management', () => {
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();

// Reset store state
useLiveKitStore.setState({
currentRoom: null,
Expand Down Expand Up @@ -386,7 +387,7 @@ describe('LiveKit Store - Permission Management', () => {
beforeEach(() => {
jest.clearAllMocks();
(Platform as any).OS = 'ios';

// Reset mock implementations
mockCallKeepService.setup.mockResolvedValue(undefined);
mockCallKeepService.startCall.mockResolvedValue('test-uuid');
Expand Down Expand Up @@ -424,31 +425,31 @@ describe('LiveKit Store - Permission Management', () => {
const uuid = await mockCallKeepService.startCall('test-room');
expect(mockCallKeepService.startCall).toHaveBeenCalledWith('test-room');
expect(uuid).toBe('test-uuid');

await mockCallKeepService.endCall();
expect(mockCallKeepService.endCall).toHaveBeenCalled();
});

it('should skip CallKeep operations on non-iOS platforms', async () => {
(Platform as any).OS = 'android';

// Verify that platform checks work as expected
expect(Platform.OS).toBe('android');

// CallKeep operations would be skipped on Android
// This test confirms the platform detection works properly
});

it('should handle CallKeep service errors gracefully', async () => {
const error = new Error('CallKeep operation failed');
mockCallKeepService.setup.mockRejectedValue(error);

try {
await mockCallKeepService.setup({});
} catch (e) {
expect(e).toBe(error);
}

expect(mockCallKeepService.setup).toHaveBeenCalled();
});
});
Expand Down
69 changes: 65 additions & 4 deletions src/stores/app/livekit-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,49 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({

requestPermissions: async () => {
try {
if (Platform.OS === 'android' || Platform.OS === 'ios') {
if (Platform.OS === 'web') {
// On web, use the browser's MediaDevices API to request microphone permission
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
// Request microphone access - this will prompt the user for permission
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Release the stream immediately - LiveKit will get its own stream
stream.getTracks().forEach((track) => {
track.stop();
});
logger.info({
message: 'Microphone permission granted successfully',
context: { platform: 'web' },
});
} catch (mediaError: any) {
if (mediaError.name === 'NotAllowedError') {
logger.error({
message: 'Microphone permission denied by user',
context: { platform: 'web', error: mediaError },
});
// Only throw on permission denied - this requires user action
throw mediaError;
} else if (mediaError.name === 'NotFoundError') {
// No microphone found - log warning but continue
// User can still listen and may connect a microphone later
logger.warn({
message: 'No microphone device found - voice channel will be listen-only until a microphone is connected',
context: { platform: 'web' },
});
} else {
logger.warn({
message: 'Failed to request microphone permission - continuing with limited audio',
context: { platform: 'web', error: mediaError },
});
}
}
} else {
logger.warn({
message: 'MediaDevices API not available in this browser',
context: { platform: 'web' },
});
}
} else if (Platform.OS === 'android' || Platform.OS === 'ios') {
// Use expo-audio for both Android and iOS microphone permissions
const micPermission = await getRecordingPermissionsAsync();

Expand Down Expand Up @@ -158,7 +200,7 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({

connectToRoom: async (roomInfo, token) => {
try {
const { currentRoom, voipServerWebsocketSslAddress } = get();
const { currentRoom, voipServerWebsocketSslAddress, requestPermissions } = get();

// Disconnect from current room if connected
if (currentRoom) {
Expand All @@ -167,6 +209,9 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({

set({ isConnecting: true });

// Request microphone permissions before connecting
await requestPermissions();

// Create a new room
const room = new Room();

Expand Down Expand Up @@ -202,8 +247,24 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
await room.connect(voipServerWebsocketSslAddress, token);

// Set microphone to muted by default, camera to disabled (audio-only call)
await room.localParticipant.setMicrophoneEnabled(false);
await room.localParticipant.setCameraEnabled(false);
// Wrap in try-catch as some platforms may not have devices available yet
try {
await room.localParticipant.setMicrophoneEnabled(false);
} catch (micError) {
logger.warn({
message: 'Failed to set initial microphone state (will be set when user transmits)',
context: { error: micError },
});
}

try {
await room.localParticipant.setCameraEnabled(false);
} catch (camError) {
logger.warn({
message: 'Failed to set initial camera state',
context: { error: camError },
});
}

// Setup audio routing based on selected devices
await setupAudioRouting(room);
Expand Down
1 change: 1 addition & 0 deletions src/translations/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@
"transmitting_on": "جاري الإرسال على {{channel}}",
"transmission_ended": "انتهى الإرسال",
"voice_disabled": "تم تعطيل الصوت",
"disconnected": "غير متصل",
"select_channel": "اختر القناة",
"select_channel_description": "اختر قناة صوتية للاتصال بها",
"change_channel_warning": "اختيار قناة جديدة سيؤدي إلى قطع الاتصال من القناة الحالية",
Expand Down
1 change: 1 addition & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@
"transmitting_on": "Transmitting on {{channel}}",
"transmission_ended": "Transmission ended",
"voice_disabled": "Voice disabled",
"disconnected": "Disconnected",
"select_channel": "Select Channel",
"select_channel_description": "Choose a voice channel to connect to",
"change_channel_warning": "Selecting a new channel will disconnect from the current one",
Expand Down
1 change: 1 addition & 0 deletions src/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@
"transmitting_on": "Transmitiendo en {{channel}}",
"transmission_ended": "Transmisión finalizada",
"voice_disabled": "Voz deshabilitada",
"disconnected": "Desconectado",
"select_channel": "Seleccionar Canal",
"select_channel_description": "Elija un canal de voz para conectarse",
"change_channel_warning": "Seleccionar un nuevo canal desconectará del actual",
Expand Down