@@ -370,25 +370,21 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
370370 } ,
371371
372372 connectToRoom : async ( roomInfo , token ) => {
373+ // Prevent concurrent connection attempts — give instant visual feedback by
374+ // setting isConnecting immediately before any async work begins.
375+ if ( get ( ) . isConnecting || get ( ) . isConnected ) {
376+ logger . warn ( {
377+ message : 'Connection already in progress or active, ignoring duplicate request' ,
378+ context : { roomName : roomInfo . Name } ,
379+ } ) ;
380+ return ;
381+ }
382+
383+ set ( { isConnecting : true } ) ;
384+
373385 try {
374386 bluetoothAudioService . ensurePttInputMonitoring ( 'livekit-store connectToRoom start' ) ;
375387
376- // Request permissions before connecting (critical for Android foreground service)
377- // On Android 14+, the foreground service with microphone type requires RECORD_AUDIO
378- // permission to be granted BEFORE the service starts
379- const permissionsGranted = await get ( ) . requestPermissions ( ) ;
380- if ( ! permissionsGranted ) {
381- logger . error ( {
382- message : 'Cannot connect to room - permissions not granted' ,
383- context : { roomName : roomInfo . Name } ,
384- } ) ;
385- Alert . alert ( 'Voice Connection Error' , 'Microphone permission is required to join a voice channel. Please grant the permission in your device settings.' , [
386- { text : 'Cancel' , style : 'cancel' } ,
387- { text : 'Open Settings' , onPress : ( ) => Linking . openSettings ( ) } ,
388- ] ) ;
389- return ;
390- }
391-
392388 const { currentRoom, voipServerWebsocketSslAddress } = get ( ) ;
393389
394390 // Validate connection parameters before attempting to connect
@@ -397,6 +393,7 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
397393 message : 'Cannot connect to room - no VoIP server address available' ,
398394 context : { roomName : roomInfo . Name } ,
399395 } ) ;
396+ set ( { isConnecting : false } ) ;
400397 Alert . alert ( 'Voice Connection Error' , 'Voice server address is not available. Please try again later.' ) ;
401398 return ;
402399 }
@@ -406,16 +403,32 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
406403 message : 'Cannot connect to room - no token provided' ,
407404 context : { roomName : roomInfo . Name } ,
408405 } ) ;
406+ set ( { isConnecting : false } ) ;
409407 Alert . alert ( 'Voice Connection Error' , 'Voice channel token is missing. Please try refreshing the voice channels.' ) ;
410408 return ;
411409 }
412410
413- // Disconnect from current room if connected (use full cleanup flow)
414- if ( currentRoom ) {
415- await get ( ) . disconnectFromRoom ( ) ;
411+ // Request permissions before connecting (critical for Android foreground service).
412+ // On Android 14+, the foreground service with microphone type requires RECORD_AUDIO
413+ // permission to be granted BEFORE the service starts.
414+ const permissionsGranted = await get ( ) . requestPermissions ( ) ;
415+ if ( ! permissionsGranted ) {
416+ logger . error ( {
417+ message : 'Cannot connect to room - permissions not granted' ,
418+ context : { roomName : roomInfo . Name } ,
419+ } ) ;
420+ set ( { isConnecting : false } ) ;
421+ Alert . alert ( 'Voice Connection Error' , 'Microphone permission is required to join a voice channel. Please grant the permission in your device settings.' , [
422+ { text : 'Cancel' , style : 'cancel' } ,
423+ { text : 'Open Settings' , onPress : ( ) => Linking . openSettings ( ) } ,
424+ ] ) ;
425+ return ;
416426 }
417427
418- set ( { isConnecting : true } ) ;
428+ // Disconnect from current room if connected
429+ if ( currentRoom ) {
430+ await currentRoom . disconnect ( ) ;
431+ }
419432
420433 // Start the native audio session before connecting (required for production builds)
421434 // In dev builds, the audio session may persist across hot reloads, but in production
@@ -466,12 +479,24 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
466479 } ) ;
467480
468481 // Connect to the room
482+ logger . info ( {
483+ message : 'Connecting to LiveKit room' ,
484+ context : {
485+ roomName : roomInfo . Name ,
486+ hasServerUrl : ! ! voipServerWebsocketSslAddress ,
487+ serverUrlPrefix : voipServerWebsocketSslAddress . substring ( 0 , 10 ) ,
488+ hasToken : ! ! token ,
489+ } ,
490+ } ) ;
469491 await room . connect ( voipServerWebsocketSslAddress , token ) ;
492+ logger . info ( {
493+ message : 'LiveKit room connected successfully' ,
494+ context : { roomName : roomInfo . Name } ,
495+ } ) ;
470496
471- // Set microphone to muted by default, camera to disabled (audio-only call)
472- await room . localParticipant . setMicrophoneEnabled ( false ) ;
473- await room . localParticipant . setCameraEnabled ( false ) ;
474-
497+ // Commit room state to the store immediately after a successful connect so
498+ // subsequent steps (setMicrophoneEnabled, setCameraEnabled, etc.) can't orphan
499+ // a live room if they throw.
475500 set ( {
476501 currentRoom : room ,
477502 currentRoomInfo : roomInfo ,
@@ -481,6 +506,17 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
481506 lastLocalMuteChangeTimestamp : Date . now ( ) ,
482507 } ) ;
483508
509+ // Set microphone to muted by default, camera to disabled (audio-only call)
510+ try {
511+ await room . localParticipant . setMicrophoneEnabled ( false ) ;
512+ await room . localParticipant . setCameraEnabled ( false ) ;
513+ } catch ( trackError ) {
514+ logger . warn ( {
515+ message : 'Failed to set initial microphone/camera state - room is still connected' ,
516+ context : { error : trackError } ,
517+ } ) ;
518+ }
519+
484520 // Setup CallKeep mute sync
485521 callKeepService . setMuteStateCallback ( async ( muted ) => {
486522 logger . info ( {
@@ -542,41 +578,33 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
542578
543579 await audioService . playConnectToAudioRoomSound ( ) ;
544580
545- // Android foreground service for background audio
546- // Only needed on Android - iOS uses CallKeep, web browsers handle audio natively
581+ // Android foreground service for background audio.
582+ // Only needed on Android - iOS uses CallKeep, web browsers handle audio natively.
583+ // NOTE: notifee.registerForegroundService() is called once at app startup
584+ // (app-initialization.service.ts). Here we only display the notification
585+ // that triggers the already-registered handler.
547586 if ( Platform . OS === 'android' ) {
548587 try {
549- const startForegroundService = async ( ) => {
550- notifee . registerForegroundService ( async ( ) => {
551- // Minimal function with no interval or tasks to reduce strain on the main thread
552- return new Promise ( ( ) => {
553- logger . debug ( {
554- message : 'Foreground service registered' ,
555- } ) ;
556- } ) ;
557- } ) ;
558-
559- // Display the notification as a foreground service
560- await notifee . displayNotification ( {
561- title : 'Active PTT Call' ,
562- body : 'There is an active PTT call in progress.' ,
563- android : {
564- channelId : 'notif' ,
565- asForegroundService : true ,
566- foregroundServiceTypes : [ AndroidForegroundServiceType . FOREGROUND_SERVICE_TYPE_MICROPHONE ] ,
567- smallIcon : 'ic_launcher' , // Ensure this icon exists in res/drawable
568- } ,
569- } ) ;
570- } ;
571-
572- await startForegroundService ( ) ;
588+ await notifee . displayNotification ( {
589+ title : 'Active PTT Call' ,
590+ body : 'There is an active PTT call in progress.' ,
591+ android : {
592+ channelId : 'notif' ,
593+ asForegroundService : true ,
594+ foregroundServiceTypes : [ AndroidForegroundServiceType . FOREGROUND_SERVICE_TYPE_MICROPHONE ] ,
595+ smallIcon : 'ic_launcher' ,
596+ } ,
597+ } ) ;
598+ logger . info ( {
599+ message : 'Android foreground service notification displayed' ,
600+ } ) ;
573601 } catch ( error ) {
574602 logger . error ( {
575- message : 'Failed to register foreground service' ,
603+ message : 'Failed to display foreground service notification ' ,
576604 context : { error } ,
577605 } ) ;
578- // Don't fail the connection if foreground service fails on Android
579- // The call will still work but may be killed in background
606+ // Don't fail the connection if the foreground service display fails.
607+ // The call will still work but may be killed when backgrounded.
580608 }
581609 }
582610
@@ -636,11 +664,7 @@ export const useLiveKitStore = create<LiveKitState>((set, get) => ({
636664
637665 // Show user-visible error so the failure is not silent in production builds
638666 const errorMessage = error instanceof Error ? error . message : 'An unknown error occurred' ;
639- Alert . alert (
640- 'Voice Connection Failed' ,
641- `Unable to connect to voice channel "${ roomInfo ?. Name || 'Unknown' } ". ${ errorMessage } ` ,
642- [ { text : 'OK' } ]
643- ) ;
667+ Alert . alert ( 'Voice Connection Failed' , `Unable to connect to voice channel "${ roomInfo ?. Name || 'Unknown' } ". ${ errorMessage } ` , [ { text : 'OK' } ] ) ;
644668 }
645669 } ,
646670
0 commit comments