diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index af6faa9..8f0fe7a 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -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 }} diff --git a/electron/main.js b/electron/main.js index bccc1cf..f064c2c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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({ diff --git a/package.json b/package.json index 2ce5d8e..1c24c57 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ }, "build": { "appId": "com.resgrid.dispatch", - "productName": "Dispatch", + "productName": "Resgrid Dispatch", "publish": null, "directories": { "output": "dist/electron" diff --git a/src/components/dispatch-console/ptt-interface.tsx b/src/components/dispatch-console/ptt-interface.tsx index 478a48a..93082d6 100644 --- a/src/components/dispatch-console/ptt-interface.tsx +++ b/src/components/dispatch-console/ptt-interface.tsx @@ -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'; @@ -10,7 +10,6 @@ import { Icon } from '@/components/ui/icon'; 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'; @@ -19,18 +18,15 @@ interface PTTInterfaceProps { 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 = ({ onPTTPress, onPTTRelease, onOpenAudioStreams, isTransmitting: externalTransmitting = false, currentChannel: externalChannel = 'Main Channel' }) => { +export const PTTInterface: React.FC = ({ 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); @@ -80,15 +76,7 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel // 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')); const handlePTTPress = useCallback(async () => { if (!isConnected) { @@ -133,9 +121,11 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel } 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 () => { @@ -207,23 +197,11 @@ export const PTTInterface: React.FC = ({ onPTTPress, onPTTRel - - - - - {currentStream ? currentStream.Name : t('dispatch.no_stream')} - - - {/* Compact Controls */} - {/* Audio Streams Button */} - - - {/* Disconnect Button (only shown when connected) */} {isConnected ? ( diff --git a/src/hooks/use-ptt.ts b/src/hooks/use-ptt.ts index e435bcd..61caa08 100644 --- a/src/hooks/use-ptt.ts +++ b/src/hooks/use-ptt.ts @@ -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 @@ -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({ @@ -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'; diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index c8f26d4..31431e4 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -116,6 +116,7 @@ jest.mock('../../../lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), + warn: jest.fn(), debug: jest.fn(), }, })); @@ -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, @@ -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'); @@ -424,17 +425,17 @@ 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 }); @@ -442,13 +443,13 @@ describe('LiveKit Store - Permission Management', () => { 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(); }); }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 95f5e10..bf12de9 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -119,7 +119,49 @@ export const useLiveKitStore = create((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(); @@ -158,7 +200,7 @@ export const useLiveKitStore = create((set, get) => ({ connectToRoom: async (roomInfo, token) => { try { - const { currentRoom, voipServerWebsocketSslAddress } = get(); + const { currentRoom, voipServerWebsocketSslAddress, requestPermissions } = get(); // Disconnect from current room if connected if (currentRoom) { @@ -167,6 +209,9 @@ export const useLiveKitStore = create((set, get) => ({ set({ isConnecting: true }); + // Request microphone permissions before connecting + await requestPermissions(); + // Create a new room const room = new Room(); @@ -202,8 +247,24 @@ export const useLiveKitStore = create((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); diff --git a/src/translations/ar.json b/src/translations/ar.json index 8d4bcab..c427766 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -746,6 +746,7 @@ "transmitting_on": "جاري الإرسال على {{channel}}", "transmission_ended": "انتهى الإرسال", "voice_disabled": "تم تعطيل الصوت", + "disconnected": "غير متصل", "select_channel": "اختر القناة", "select_channel_description": "اختر قناة صوتية للاتصال بها", "change_channel_warning": "اختيار قناة جديدة سيؤدي إلى قطع الاتصال من القناة الحالية", diff --git a/src/translations/en.json b/src/translations/en.json index 984d940..302d6a8 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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", diff --git a/src/translations/es.json b/src/translations/es.json index c4ce83c..0972196 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -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",