Skip to content

Commit 76e13e6

Browse files
authored
Merge pull request #225 from Resgrid/develop
RU-T47 Minor fix for voice call join
2 parents 598bb30 + 7a9e560 commit 76e13e6

File tree

3 files changed

+126
-59
lines changed

3 files changed

+126
-59
lines changed

src/services/app-initialization.service.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import notifee from '@notifee/react-native';
12
import { Platform } from 'react-native';
23

34
import { logger } from '../lib/logging';
@@ -72,6 +73,13 @@ class AppInitializationService {
7273
message: 'Starting app initialization',
7374
});
7475

76+
// Register the Notifee foreground service handler for Android.
77+
// Per Notifee documentation this MUST be called at the JS root level before
78+
// any component rendering — calling it lazily inside a store action causes
79+
// the Android foreground service to start without a registered JS handler,
80+
// which silently prevents the PTT/voice call from working in production.
81+
this._registerAndroidForegroundService();
82+
7583
// Initialize CallKeep for iOS background audio support
7684
await this._initializeCallKeep();
7785

@@ -85,6 +93,39 @@ class AppInitializationService {
8593
// e.g., analytics, crash reporting, background services, etc.
8694
}
8795

96+
/**
97+
* Register the Notifee foreground service task handler for Android.
98+
* This keeps the voice channel alive when the app is in the background.
99+
* Must be called synchronously before any React component renders.
100+
*/
101+
private _registerAndroidForegroundService(): void {
102+
if (Platform.OS !== 'android') {
103+
return;
104+
}
105+
106+
try {
107+
notifee.registerForegroundService((_notification) => {
108+
// Return a never-resolving Promise to keep the foreground service alive.
109+
// The service is stopped explicitly by calling notifee.stopForegroundService()
110+
// when the voice call is disconnected.
111+
return new Promise<void>(() => {
112+
logger.debug({
113+
message: 'Android LiveKit foreground service handler running',
114+
});
115+
});
116+
});
117+
118+
logger.info({
119+
message: 'Android foreground service handler registered at startup',
120+
});
121+
} catch (error) {
122+
logger.error({
123+
message: 'Failed to register Android foreground service handler',
124+
context: { error },
125+
});
126+
}
127+
}
128+
88129
/**
89130
* Initialize CallKeep service for iOS and Android
90131
*/

src/services/bluetooth-audio.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1491,7 +1491,9 @@ class BluetoothAudioService {
14911491
void this.pollReadCharacteristics(deviceId).finally(() => {
14921492
this.isReadPollingInFlight = false;
14931493
});
1494-
}, 700);
1494+
// 1500ms interval: reduced from 700ms to lower BLE log spam and CPU overhead
1495+
// while still providing sub-2-second PTT button responsiveness
1496+
}, 1500);
14951497
}
14961498

14971499
private async pollReadCharacteristics(deviceId: string): Promise<void> {

src/stores/app/livekit-store.ts

Lines changed: 82 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)