From 06e24ba9cc89f9f59e7a8b8c745e3db53ec1d805 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 30 Jan 2026 17:02:34 -0800 Subject: [PATCH 1/4] RR-T40 PTT fixes for audio routing and chime sounds. --- app.config.ts | 1 + plugins/withInCallAudioModule.js | 261 ++++++++++++++++++ .../livekit/livekit-bottom-sheet.tsx | 1 + ...luetooth-device-selection-bottom-sheet.tsx | 63 ++++- .../livekit-call/store/useLiveKitCallStore.ts | 13 + src/services/audio.service.ts | 65 ++++- src/stores/app/livekit-store.ts | 85 ++++-- src/utils/InCallAudio.ts | 96 +++++++ 8 files changed, 541 insertions(+), 44 deletions(-) create mode 100644 plugins/withInCallAudioModule.js create mode 100644 src/utils/InCallAudio.ts diff --git a/app.config.ts b/app.config.ts index e2d8ca5..148d795 100644 --- a/app.config.ts +++ b/app.config.ts @@ -278,6 +278,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ '@config-plugins/react-native-callkeep', './customGradle.plugin.js', './customManifest.plugin.js', + './plugins/withInCallAudioModule.js', ['app-icon-badge', appIconBadgeConfig], ], extra: { diff --git a/plugins/withInCallAudioModule.js b/plugins/withInCallAudioModule.js new file mode 100644 index 0000000..e6e3c45 --- /dev/null +++ b/plugins/withInCallAudioModule.js @@ -0,0 +1,261 @@ +const { withDangerousMod, withMainApplication } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * Android InCallAudioModule.kt content + * Uses SoundPool to play sounds on the VOICE_COMMUNICATION stream. + */ +const ANDROID_MODULE = `package {{PACKAGE_NAME}} + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.os.Build +import android.util.Log +import com.facebook.react.bridge.* + +class InCallAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + companion object { + private const val TAG = "InCallAudioModule" + } + + private var soundPool: SoundPool? = null + private val soundMap = HashMap() + private val loadedSounds = HashSet() + private var isInitialized = false + + override fun getName(): String { + return "InCallAudioModule" + } + + @ReactMethod + fun initializeAudio() { + if (isInitialized) return + + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + + soundPool = SoundPool.Builder() + .setMaxStreams(1) + .setAudioAttributes(audioAttributes) + .build() + + soundPool?.setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) { + loadedSounds.add(sampleId) + Log.d(TAG, "Sound loaded successfully: $sampleId") + } else { + Log.e(TAG, "Failed to load sound $sampleId, status: $status") + } + } + + isInitialized = true + Log.d(TAG, "InCallAudioModule initialized with USAGE_VOICE_COMMUNICATION") + } + + @ReactMethod + fun loadSound(name: String, resourceName: String) { + if (!isInitialized) initializeAudio() + + val context = reactApplicationContext + var resId = context.resources.getIdentifier(resourceName, "raw", context.packageName) + + // Fallback: Try identifying without package name if first attempt fails (though context.packageName is usually correct) + if (resId == 0) { + Log.w(TAG, "Resource $resourceName not found in \${context.packageName}, trying simplified lookup") + // Reflection-based lookup if needed, but getIdentifier is standard. + } + + if (resId != 0) { + soundPool?.let { pool -> + val soundId = pool.load(context, resId, 1) + soundMap[name] = soundId + Log.d(TAG, "Loading sound: $name from resource: $resourceName (id: $soundId, resId: $resId)") + } + } else { + Log.e(TAG, "Resource not found: $resourceName in package \${context.packageName}") + } + } + + @ReactMethod + fun playSound(name: String) { + val soundId = soundMap[name] + if (soundId != null) { + if (loadedSounds.contains(soundId)) { + val streamId = soundPool?.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f) + if (streamId == 0) { + Log.e(TAG, "Failed to play sound: $name (id: $soundId). StreamId is 0. Check Volume/Focus.") + } else { + Log.d(TAG, "Playing sound: $name (id: $soundId, stream: $streamId)") + } + } else { + Log.w(TAG, "Sound $name (id: $soundId) is not ready yet. Ignoring play request.") + } + } else { + Log.w(TAG, "Sound not found in map: $name") + } + } + + @ReactMethod + fun cleanup() { + soundPool?.release() + soundPool = null + soundMap.clear() + loadedSounds.clear() + isInitialized = false + Log.d(TAG, "InCallAudioModule cleaned up") + } +} +`; + +/** + * Android InCallAudioPackage.kt content + */ +const ANDROID_PACKAGE = `package {{PACKAGE_NAME}} + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class InCallAudioPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(InCallAudioModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List>> { + return emptyList() + } +} +`; + +/** + * Helper to resolve package name + */ +function resolveBasePackageName(projectRoot, fallback = 'com.resgrid.unit') { + const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/; + + const groovyPath = path.join(projectRoot, 'android', 'app', 'build.gradle'); + if (fs.existsSync(groovyPath)) { + const content = fs.readFileSync(groovyPath, 'utf-8'); + const match = content.match(namespaceRegex); + if (match) return match[1]; + } + + const ktsPath = path.join(projectRoot, 'android', 'app', 'build.gradle.kts'); + if (fs.existsSync(ktsPath)) { + const content = fs.readFileSync(ktsPath, 'utf-8'); + const match = content.match(namespaceRegex); + if (match) return match[1]; + } + + return fallback; +} + +const withInCallAudioModule = (config) => { + // 1. Copy Assets to Android res/raw + config = withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const resRawPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res', 'raw'); + + if (!fs.existsSync(resRawPath)) { + fs.mkdirSync(resRawPath, { recursive: true }); + } + + const assets = ['software_interface_start.mp3', 'software_interface_back.mp3', 'positive_interface_beep.mp3', 'space_notification1.mp3', 'space_notification2.mp3']; + + const sourceBase = path.join(projectRoot, 'assets', 'audio', 'ui'); + + assets.forEach((filename) => { + const sourcePath = path.join(sourceBase, filename); + const destPath = path.join(resRawPath, filename); + + if (fs.existsSync(sourcePath)) { + fs.copyFileSync(sourcePath, destPath); + console.log(`[withInCallAudioModule] Copied ${filename} to res/raw/${filename}`); + } else { + console.warn(`[withInCallAudioModule] Source audio file not found: ${sourcePath}`); + } + }); + + return config; + }, + ]); + + // 2. Add Native Module Code + config = withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const packageName = resolveBasePackageName(projectRoot); + const packagePath = packageName.replace(/\./g, '/'); + const androidSrcPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', packagePath); + + if (!fs.existsSync(androidSrcPath)) { + fs.mkdirSync(androidSrcPath, { recursive: true }); + } + + // InCallAudioModule.kt + const modulePath = path.join(androidSrcPath, 'InCallAudioModule.kt'); + const moduleContent = ANDROID_MODULE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(modulePath, moduleContent); + console.log('[withInCallAudioModule] Created InCallAudioModule.kt'); + + // InCallAudioPackage.kt + const packageFilePath = path.join(androidSrcPath, 'InCallAudioPackage.kt'); + const packageContent = ANDROID_PACKAGE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(packageFilePath, packageContent); + console.log('[withInCallAudioModule] Created InCallAudioPackage.kt'); + + return config; + }, + ]); + + // 3. Register Package in MainApplication.kt + config = withMainApplication(config, (config) => { + const mainApplication = config.modResults; + const projectRoot = config.modRequest.projectRoot; + + if (!mainApplication.contents.includes('InCallAudioPackage')) { + const basePackageName = resolveBasePackageName(projectRoot); + const importStatement = `import ${basePackageName}.InCallAudioPackage`; + + if (!mainApplication.contents.includes(importStatement)) { + mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`); + } + + const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/; + const packagesMatch = mainApplication.contents.match(packagesPattern); + + if (packagesMatch) { + // Using the simplest replacement that ensures toMutableList() + const replacement = `val packages = PackageList(this).packages.toMutableList()\n packages.add(InCallAudioPackage())`; + + // Avoid double adding if MediaButtonPackage logic already changed it to mutable + if (mainApplication.contents.includes('packages.add(MediaButtonPackage()')) { + // Add ours after MediaButtonPackage + mainApplication.contents = mainApplication.contents.replace('packages.add(MediaButtonPackage())', 'packages.add(MediaButtonPackage())\n packages.add(InCallAudioPackage())'); + } else { + // Standard replacement + mainApplication.contents = mainApplication.contents.replace(packagesPattern, replacement); + } + console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt'); + } + } + + return config; + }); + + return config; +}; + +module.exports = withInCallAudioModule; diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx index a2c0b63..915959a 100644 --- a/src/components/livekit/livekit-bottom-sheet.tsx +++ b/src/components/livekit/livekit-bottom-sheet.tsx @@ -28,6 +28,7 @@ export const LiveKitBottomSheet = () => { useLiveKitStore(); const { selectedAudioDevices } = useBluetoothAudioStore(); + const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx index 13fa901..12d2edc 100644 --- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx +++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx @@ -1,7 +1,7 @@ import { BluetoothIcon, RefreshCwIcon, WifiIcon } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, useWindowDimensions } from 'react-native'; +import { useWindowDimensions } from 'react-native'; import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; @@ -13,6 +13,7 @@ import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useToast } from '@/hooks/use-toast'; import { usePreferredBluetoothDevice } from '@/lib/hooks/use-preferred-bluetooth-device'; import { logger } from '@/lib/logging'; import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; @@ -30,9 +31,11 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo const { width, height } = useWindowDimensions(); const isLandscape = width > height; const { trackEvent } = useAnalytics(); + const toast = useToast(); const { preferredDevice, setPreferredDevice } = usePreferredBluetoothDevice(); const { availableDevices, isScanning, bluetoothState, connectedDevice, connectionError } = useBluetoothAudioStore(); const [hasScanned, setHasScanned] = useState(false); + const [connectingDeviceId, setConnectingDeviceId] = useState(null); // Analytics tracking function for bottom sheet view const trackViewAnalytics = useCallback(() => { @@ -105,13 +108,18 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo bluetoothState, }); - Alert.alert(t('bluetooth.scan_error_title'), error instanceof Error ? error.message : t('bluetooth.scan_error_message'), [{ text: t('common.ok') }]); + toast.error(error instanceof Error ? error.message : t('bluetooth.scan_error_message'), t('bluetooth.scan_error_title')); } - }, [t, trackEvent, bluetoothState, availableDevices.length, preferredDevice, connectedDevice]); + }, [t, trackEvent, bluetoothState, availableDevices.length, preferredDevice, connectedDevice, toast]); const handleDeviceSelect = React.useCallback( async (device: BluetoothAudioDevice) => { + // Prevent multiple concurrent connection attempts + if (connectingDeviceId) return; + try { + setConnectingDeviceId(device.id); + // Track device selection start trackEvent('bluetooth_device_selection_started', { timestamp: new Date().toISOString(), @@ -179,6 +187,9 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo wasSuccessful: true, hadToDisconnectPrevious: !!connectedDevice, }); + + // Only close on success + onClose(); } catch (connectionError) { logger.warn({ message: 'Failed to connect to selected device immediately', @@ -194,10 +205,10 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo connectionError: connectionError instanceof Error ? connectionError.message : 'Unknown connection error', hadToDisconnectPrevious: !!connectedDevice, }); - // Don't show error to user as they may just want to set preference - } - onClose(); + const errorMessage = connectionError instanceof Error ? connectionError.message : t('bluetooth.connection_error_message', 'Failed to connect to device'); + toast.error(errorMessage, t('common.error')); + } } catch (error) { logger.error({ message: 'Failed to set preferred Bluetooth device', @@ -212,10 +223,13 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo errorMessage: error instanceof Error ? error.message : 'Unknown error', }); - Alert.alert(t('bluetooth.selection_error_title'), t('bluetooth.selection_error_message'), [{ text: t('common.ok') }]); + const errorMessage = error instanceof Error ? error.message : t('bluetooth.selection_error_message'); + toast.error(errorMessage, t('bluetooth.selection_error_title')); + } finally { + setConnectingDeviceId(null); } }, - [setPreferredDevice, onClose, t, connectedDevice, trackEvent, preferredDevice] + [setPreferredDevice, onClose, t, connectedDevice, trackEvent, preferredDevice, connectingDeviceId, toast] ); const handleClearSelection = React.useCallback(async () => { @@ -243,8 +257,11 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo timestamp: new Date().toISOString(), errorMessage: error instanceof Error ? error.message : 'Unknown error', }); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast.error(errorMessage, t('common.error')); } - }, [setPreferredDevice, onClose, trackEvent, preferredDevice, connectedDevice]); + }, [setPreferredDevice, onClose, trackEvent, preferredDevice, connectedDevice, toast, t]); const stopScan = React.useCallback(() => { if (isScanning) { @@ -268,16 +285,25 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo ({ item }: { item: BluetoothAudioDevice }) => { const isSelected = preferredDevice?.id === item.id; const isConnected = connectedDevice?.id === item.id; + const isConnecting = connectingDeviceId === item.id; + const isDisabled = connectingDeviceId !== null; return ( handleDeviceSelect(item)} - className={`mb-2 rounded-lg border p-4 ${isSelected ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'}`} + onPress={() => !isDisabled && handleDeviceSelect(item)} + disabled={isDisabled} + className={`mb-2 rounded-lg border p-4 ${isSelected ? 'border-primary-500 bg-primary-50 dark:bg-primary-950' : 'border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800'} ${isDisabled && !isConnecting ? 'opacity-50' : ''}`} > - + {isConnecting ? ( + + + + ) : ( + + )} {item.name || t('bluetooth.unknown_device')} {isConnected && } @@ -297,7 +323,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo ); }, - [preferredDevice, connectedDevice, handleDeviceSelect, t] + [preferredDevice, connectedDevice, handleDeviceSelect, t, connectingDeviceId] ); const renderEmptyState = useCallback(() => { @@ -352,7 +378,16 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo {/* Device List */} - item.id} ListEmptyComponent={renderEmptyState} className="flex-1" showsVerticalScrollIndicator={false} /> + + item.id} + ListEmptyComponent={renderEmptyState} + showsVerticalScrollIndicator={false} + estimatedItemSize={94} + /> + {/* Bluetooth State Info */} {bluetoothState !== State.PoweredOn && ( diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts index a02cf47..cf318cf 100644 --- a/src/features/livekit-call/store/useLiveKitCallStore.ts +++ b/src/features/livekit-call/store/useLiveKitCallStore.ts @@ -4,6 +4,7 @@ import { create } from 'zustand'; import { logger } from '../../../lib/logging'; import { callKeepService } from '../../../services/callkeep.service.ios'; +import { inCallAudio } from '../../../utils/InCallAudio'; export interface RoomInfo { id: string; @@ -105,6 +106,8 @@ export const useLiveKitCallStore = create((set, get) => ({ newRoom.localParticipant.setMicrophoneEnabled(true); newRoom.localParticipant.setCameraEnabled(false); // No video + inCallAudio.playSound('connected'); + // Start CallKeep call for iOS background audio support if (Platform.OS === 'ios') { callKeepService @@ -132,6 +135,8 @@ export const useLiveKitCallStore = create((set, get) => ({ localParticipant: null, // Keep error if there was one leading to disconnect }); + + inCallAudio.playSound('disconnected'); // End CallKeep call for iOS when disconnected if (Platform.OS === 'ios') { @@ -288,6 +293,14 @@ export const useLiveKitCallStore = create((set, get) => ({ try { await room.localParticipant.setMicrophoneEnabled(enabled); get().actions._updateParticipants(); // reflect change in participant state + + // Play transmit sounds + if (enabled) { + inCallAudio.playSound('transmit_start'); + } else { + inCallAudio.playSound('transmit_stop'); + } + logger.info({ message: 'Microphone state changed', context: { enabled, participantIdentity: room.localParticipant.identity }, diff --git a/src/services/audio.service.ts b/src/services/audio.service.ts index 0d8736b..53e0229 100644 --- a/src/services/audio.service.ts +++ b/src/services/audio.service.ts @@ -1,9 +1,11 @@ import { Asset } from 'expo-asset'; import { Audio, type AVPlaybackSource, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av'; -import { Platform } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; import { logger } from '@/lib/logging'; +const { InCallAudioModule } = NativeModules; + class AudioService { private static instance: AudioService; private startTransmittingSound: Audio.Sound | null = null; @@ -13,6 +15,7 @@ class AudioService { private disconnectedFromAudioRoomSound: Audio.Sound | null = null; private isInitialized = false; private isPlayingSound: Set = new Set(); + private isNativeAudioInitialized = false; private constructor() { this.initializeAudio(); @@ -46,6 +49,11 @@ class AudioService { interruptionModeAndroid: InterruptionModeAndroid.DuckOthers, }); + // Initialize native audio module on Android + if (Platform.OS === 'android') { + this.initializeNativeAudio(); + } + // Pre-load audio assets for production builds await this.preloadAudioAssets(); @@ -65,6 +73,34 @@ class AudioService { } } + private initializeNativeAudio(): void { + if (this.isNativeAudioInitialized) return; + + try { + if (InCallAudioModule) { + InCallAudioModule.initializeAudio?.(); + + // Map sound names to resource names (must match files in res/raw copied by plugin) + // verify these match the filenames in plugins/withInCallAudioModule.js + InCallAudioModule.loadSound('startTransmitting', 'space_notification1'); + InCallAudioModule.loadSound('stopTransmitting', 'space_notification2'); + InCallAudioModule.loadSound('connectedDevice', 'positive_interface_beep'); + InCallAudioModule.loadSound('connectedToAudioRoom', 'software_interface_start'); + InCallAudioModule.loadSound('disconnectedFromAudioRoom', 'software_interface_back'); + + this.isNativeAudioInitialized = true; + logger.info({ message: 'Native InCallAudioModule initialized (Android)' }); + } else { + logger.warn({ message: 'InCallAudioModule not found on Android' }); + } + } catch (error) { + logger.error({ + message: 'Failed to initialize native in-call audio', + context: { error }, + }); + } + } + public async preloadAudioAssets(): Promise { try { await Promise.all([ @@ -155,6 +191,24 @@ class AudioService { } private async playSound(sound: Audio.Sound | null, soundName: string): Promise { + // If Android and Native Module is available, use it for specific sounds + if (Platform.OS === 'android' && this.isNativeAudioInitialized && InCallAudioModule) { + try { + InCallAudioModule.playSound(soundName); + logger.debug({ + message: 'Sound played via native module (Android)', + context: { soundName }, + }); + return; + } catch (error) { + logger.warn({ + message: 'Failed to play native sound, falling back to expo-av', + context: { soundName, error }, + }); + // Fallthrough to expo-av if native fails + } + } + if (!sound) { logger.warn({ message: `Sound not loaded: ${soundName}`, @@ -282,6 +336,15 @@ class AudioService { // Clear the playing sound set this.isPlayingSound.clear(); + if (Platform.OS === 'android' && this.isNativeAudioInitialized && InCallAudioModule) { + try { + InCallAudioModule.cleanup?.(); + this.isNativeAudioInitialized = false; + } catch (e) { + /* ignore */ + } + } + // Unload start transmitting sound if (this.startTransmittingSound) { await this.startTransmittingSound.unloadAsync(); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 5d80503..59486a1 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -1,3 +1,4 @@ +import { AudioSession } from '@livekit/react-native'; import notifee, { AndroidImportance } from '@notifee/react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; import { Audio } from 'expo-av'; @@ -13,54 +14,80 @@ import { headsetButtonService } from '../../services/headset-button.service'; import { toggleMicrophone } from '../../utils/microphone-toggle'; import { useBluetoothAudioStore } from './bluetooth-audio-store'; -// Helper function to setup audio routing based on selected devices // Helper function to setup audio routing based on selected devices const setupAudioRouting = async (room: Room): Promise => { try { const bluetoothStore = useBluetoothAudioStore.getState(); const { selectedAudioDevices } = bluetoothStore; const speaker = selectedAudioDevices.speaker; - const microphone = selectedAudioDevices.microphone; logger.info({ message: 'Setting up audio routing', context: { speakerType: speaker?.type, speakerName: speaker?.name, - micType: microphone?.type, + platform: Platform.OS, }, }); - if (Platform.OS === 'android' || Platform.OS === 'ios') { - // Default configuration for voice call - const audioModeConfig: any = { - allowsRecordingIOS: true, - staysActiveInBackground: true, - playsInSilentModeIOS: true, - shouldDuckAndroid: true, - // Default to earpiece unless speaker is explicitly selected - playThroughEarpieceAndroid: true, - }; - - // If speaker device is selected (explicitly 'speaker' type), force speaker output - if (speaker?.type === 'speaker') { - logger.debug({ message: 'Routing audio to Speakerphone' }); - audioModeConfig.playThroughEarpieceAndroid = false; - - // On iOS, we might need to handle this differently if we wanted to force speaker, - // but typically standard routing handles it or AVRoutePickerView is used. - // For Expo AV, we can sometimes influence it. + if (Platform.OS === 'android') { + let outputType = 'speaker'; // default + + if (speaker?.type === 'bluetooth') { + outputType = 'bluetooth'; + } else if (speaker?.type === 'wired') { + outputType = 'headset'; + } else if (speaker?.type === 'speaker') { + outputType = 'speaker'; } else { - logger.debug({ message: 'Routing audio to Earpiece/Headset' }); - audioModeConfig.playThroughEarpieceAndroid = true; + outputType = 'earpiece'; } - await Audio.setAudioModeAsync(audioModeConfig); - } + logger.debug({ message: `Routing audio to ${outputType} on Android` }); + + try { + // Ensure we are in a call-compatible mode + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + staysActiveInBackground: true, + playsInSilentModeIOS: true, + shouldDuckAndroid: true, + playThroughEarpieceAndroid: outputType !== 'speaker', + }); + + if (outputType === 'bluetooth') { + await AudioSession.startAudioSession(); + } - // Handle LiveKit specific device switching if needed (mostly for web/desktop, but good to have) - if (speaker?.id && speaker.id !== 'default-speaker' && speaker.type === 'bluetooth') { - // logic for specific bluetooth device selection if feasible + await AudioSession.selectAudioOutput(outputType); + } catch (e) { + logger.warn({ message: 'Failed to select audio output via AudioSession', context: { error: e } }); + } + } else if (Platform.OS === 'ios') { + await AudioSession.startAudioSession(); + + if (speaker?.type === 'bluetooth') { + // Bluetooth preferred + await AudioSession.setAppleAudioConfiguration({ + audioCategory: 'playAndRecord', + audioCategoryOptions: ['allowBluetooth', 'allowBluetoothA2DP', 'mixWithOthers'], + audioMode: 'voiceChat', + }); + } else if (speaker?.type === 'speaker') { + // Force speaker + await AudioSession.setAppleAudioConfiguration({ + audioCategory: 'playAndRecord', + audioCategoryOptions: ['defaultToSpeaker', 'mixWithOthers'], + audioMode: 'videoChat', + }); + } else { + // Earpiece / Default + await AudioSession.setAppleAudioConfiguration({ + audioCategory: 'playAndRecord', + audioCategoryOptions: ['mixWithOthers'], + audioMode: 'voiceChat', + }); + } } } catch (error) { logger.error({ diff --git a/src/utils/InCallAudio.ts b/src/utils/InCallAudio.ts new file mode 100644 index 0000000..1c72029 --- /dev/null +++ b/src/utils/InCallAudio.ts @@ -0,0 +1,96 @@ +import { Audio, InterruptionModeIOS } from 'expo-av'; +import { NativeModules, Platform } from 'react-native'; + +import { logger } from '../lib/logging'; + +const { InCallAudioModule } = NativeModules; + +// Map logical names to resource names (Android) and require paths (iOS) +const SOUNDS = { + connected: { + android: 'space_notification1', + ios: require('../../assets/audio/ui/space_notification1.mp3'), + }, + disconnected: { + android: 'space_notification2', + ios: require('../../assets/audio/ui/space_notification2.mp3'), + }, + transmit_start: { + android: 'positive_interface_beep', + ios: require('../../assets/audio/ui/positive_interface_beep.mp3'), + }, + transmit_stop: { + android: 'software_interface_back', + ios: require('../../assets/audio/ui/software_interface_back.mp3'), + }, +} as const; + +type SoundName = keyof typeof SOUNDS; + +class InCallAudioService { + private isInitialized = false; + + constructor() { + this.initialize(); + } + + public initialize() { + if (Platform.OS === 'android') { + try { + if (InCallAudioModule) { + InCallAudioModule.initializeAudio?.(); + // Preload sounds + Object.entries(SOUNDS).forEach(([name, config]) => { + InCallAudioModule.loadSound(name, config.android); + }); + this.isInitialized = true; + logger.info({ message: 'InCallAudio initialized (Android)' }); + } else { + logger.warn({ message: 'InCallAudioModule not found on Android' }); + } + } catch (error) { + logger.error({ message: 'Failed to initialize InCallAudio (Android)', context: { error } }); + } + } else { + // iOS / Web: expo-av handles loading on play or we can preload if needed, + // but simple play usually works fine. + Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, + staysActiveInBackground: true, + shouldDuckAndroid: false, + }).catch((err) => logger.warn({ message: 'Failed to set audio mode (iOS)', context: { error: err } })); + + this.isInitialized = true; + } + } + + public async playSound(name: SoundName) { + if (!this.isInitialized) { + this.initialize(); + } + + try { + if (Platform.OS === 'android') { + if (InCallAudioModule) { + InCallAudioModule.playSound(name); + } + } else { + // iOS + const source = SOUNDS[name].ios; + const { sound } = await Audio.Sound.createAsync(source); + await sound.playAsync(); + // Unload after playback to free resources + sound.setOnPlaybackStatusUpdate(async (status) => { + if (status.isLoaded && status.didJustFinish) { + await sound.unloadAsync(); + } + }); + } + } catch (error) { + logger.warn({ message: 'Failed to play in-call sound', context: { name, error } }); + } + } +} + +export const inCallAudio = new InCallAudioService(); From 71adda41e5067ac00cd928070124d0e7a02890c0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 31 Jan 2026 23:21:15 -0800 Subject: [PATCH 2/4] RR-T40 Trying to fix ptt issues. --- .agent/rules/rules.md | 66 +++ jest-setup.ts | 60 ++ package.json | 2 +- src/app/(app)/__tests__/calendar.test.tsx | 5 + src/app/(app)/__tests__/map.test.tsx | 5 + src/app/(app)/__tests__/messages.test.tsx | 5 + src/app/(app)/__tests__/notes.test.tsx | 5 + src/app/(app)/__tests__/protocols.test.tsx | 5 + src/app/(app)/__tests__/settings.test.tsx | 5 + src/app/(app)/__tests__/shifts.test.tsx | 5 + src/app/(app)/__tests__/units.test.tsx | 5 + src/app/(app)/home/__tests__/calls.test.tsx | 6 + src/app/(app)/home/__tests__/index.test.tsx | 6 + src/app/__tests__/onboarding.test.tsx | 4 + src/app/login/__tests__/index.test.tsx | 5 + src/app/login/__tests__/login-form.test.tsx | 5 + .../audio-stream-bottom-sheet.test.tsx | 5 + .../__tests__/bluetooth-audio-modal.test.tsx | 5 + ...ndar-item-details-sheet-analytics.test.tsx | 5 + ...lendar-item-details-sheet-minimal.test.tsx | 5 + .../calendar-item-details-sheet.test.tsx | 5 + .../call-detail-menu-integration.test.tsx | 5 + .../calls/__tests__/call-detail-menu.test.tsx | 5 + .../calls/__tests__/call-files-modal.test.tsx | 5 + .../__tests__/call-images-modal.test.tsx | 6 + .../calls/__tests__/call-notes-modal.test.tsx | 5 + .../close-call-bottom-sheet.test.tsx | 5 + .../__tests__/contact-details-sheet.test.tsx | 5 + .../compose-message-sheet-simple.test.tsx | 5 + .../__tests__/compose-message-sheet.test.tsx | 5 + .../__tests__/message-details-sheet.test.tsx | 5 + .../note-details-sheet-analytics.test.tsx | 5 + .../note-details-sheet-integration.test.tsx | 5 + .../__tests__/note-details-sheet.test.tsx | 32 +- .../personnel-details-sheet.test.tsx | 5 + .../__tests__/personnel-filter-sheet.test.tsx | 5 + .../login-info-bottom-sheet.test.tsx | 10 + .../server-url-bottom-sheet.test.tsx | 5 + ...luetooth-device-selection-bottom-sheet.tsx | 37 +- .../__tests__/shift-details-sheet.test.tsx | 6 + .../__tests__/staffing-bottom-sheet.test.tsx | 5 + .../personnel-status-analytics.test.tsx | 5 + .../personnel-status-bottom-sheet.test.tsx | 5 + .../personnel-status-integration.test.tsx | 5 + src/components/ui/bottom-sheet.tsx | 2 +- .../__tests__/unit-details-sheet.test.tsx | 5 + .../__tests__/units-filter-sheet.test.tsx | 5 + .../components/LiveKitCallModal.tsx | 154 ----- .../__tests__/useLiveKitCallStore.test.ts | 550 ------------------ .../livekit-call/store/useLiveKitCallStore.ts | 361 ------------ src/services/__tests__/audio.service.test.ts | 2 +- .../__tests__/bluetooth-audio-forget.test.ts | 117 ++++ .../__tests__/headset-button.service.test.ts | 2 + src/services/audio.service.ts | 2 +- src/services/bluetooth-audio.service.ts | 183 +++--- src/services/callkeep.service.android.ts | 314 ++++++++-- src/services/callkeep.service.ios.ts | 104 +++- src/services/headset-button.service.ts | 168 +++--- .../app/__tests__/livekit-store.test.ts | 2 + src/stores/app/bluetooth-audio-store.ts | 22 +- src/stores/app/livekit-store.ts | 151 ++++- src/utils/InCallAudio.ts | 90 ++- src/utils/microphone-toggle.ts | 4 + 63 files changed, 1289 insertions(+), 1349 deletions(-) create mode 100644 .agent/rules/rules.md delete mode 100644 src/features/livekit-call/components/LiveKitCallModal.tsx delete mode 100644 src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts delete mode 100644 src/features/livekit-call/store/useLiveKitCallStore.ts create mode 100644 src/services/__tests__/bluetooth-audio-forget.test.ts diff --git a/.agent/rules/rules.md b/.agent/rules/rules.md new file mode 100644 index 0000000..7168799 --- /dev/null +++ b/.agent/rules/rules.md @@ -0,0 +1,66 @@ +You are an expert in TypeScript, React Native, Expo, and Mobile App Development. + +Code Style and Structure: + +- Write concise, type-safe TypeScript code. +- Use functional components and hooks over class components. +- Ensure components are modular, reusable, and maintainable. +- Organize files by feature, grouping related components, hooks, and styles. +- This is a mobile application, so ensure all components are mobile friendly and responsive and support both iOS and Android platforms and ensure that the app is optimized for both platforms. + +Naming Conventions: + +- Use camelCase for variable and function names (e.g., `isFetchingData`, `handleUserInput`). +- Use PascalCase for component names (e.g., `UserProfile`, `ChatScreen`). +- Directory and File names should be lowercase and hyphenated (e.g., `user-profile`, `chat-screen`). + +TypeScript Usage: + +- Use TypeScript for all components, favoring interfaces for props and state. +- Enable strict typing in `tsconfig.json`. +- Avoid using `any`; strive for precise types. +- Utilize `React.FC` for defining functional components with props. + +Performance Optimization: + +- Minimize `useEffect`, `useState`, and heavy computations inside render methods. +- Use `React.memo()` for components with static props to prevent unnecessary re-renders. +- Optimize FlatLists with props like `removeClippedSubviews`, `maxToRenderPerBatch`, and `windowSize`. +- Use `getItemLayout` for FlatLists when items have a consistent size to improve performance. +- Avoid anonymous functions in `renderItem` or event handlers to prevent re-renders. + +UI and Styling: + +- Use consistent styling leveraging `gluestack-ui`. If there isn't a Gluestack component in the `components/ui` directory for the component you are trying to use consistently style it either through `StyleSheet.create()` or Styled Components. +- Ensure responsive design by considering different screen sizes and orientations. +- Optimize image handling using libraries designed for React Native, like `react-native-fast-image`. + +Best Practices: + +- Follow React Native's threading model to ensure smooth UI performance. +- Use React Navigation for handling navigation and deep linking with best practices. +- Create and use Jest to test to validate all generated components +- Generate tests for all components, services and logic generated. Ensure tests run without errors and fix any issues. +- The app is multi-lingual, so ensure all text is wrapped in `t()` from `react-i18next` for translations with the dictonary files stored in `src/translations`. +- Ensure support for dark mode and light mode. +- Ensure the app is accessible, following WCAG guidelines for mobile applications. +- Make sure the app is optimized for performance, especially for low-end devices. +- Handle errors gracefully and provide user feedback. +- Implement proper offline support. +- Ensure the user interface is intuitive and user-friendly and works seamlessly across different devices and screen sizes. +- This is an expo managed project that uses prebuild, do not make native code changes outside of expo prebuild capabilities. + +Additional Rules: + +- Use `yarn` as the package manager. +- Use Expo's secure store for sensitive data +- Implement proper offline support +- Use `zustand` for state management +- Use `react-hook-form` for form handling +- Use `react-query` for data fetching +- Use `react-i18next` for internationalization +- Use `react-native-mmkv` for local storage +- Use `axios` for API requests +- Use `@rnmapbox/maps` for maps, mapping or vehicle navigation +- Use `lucide-react-native` for icons and use those components directly in the markup and don't use the gluestack-ui icon component +- Use ? : for conditional rendering and not && diff --git a/jest-setup.ts b/jest-setup.ts index e31e563..72f9ba8 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -320,6 +320,43 @@ jest.mock('react-native', () => { addEventListener: jest.fn(), removeEventListener: jest.fn(), }, + + // NativeEventEmitter + NativeEventEmitter: class { + addListener = jest.fn(); + removeListener = jest.fn(); + removeAllListeners = jest.fn(); + }, + + // NativeModules + NativeModules: { + InCallAudioModule: { + initializeAudio: jest.fn(), + loadSound: jest.fn(), + playSound: jest.fn(), + cleanup: jest.fn(), + }, + LivekitReactNativeModule: { + // Add stub methods as needed + configureAudio: jest.fn(), + startAudioSession: jest.fn(), + stopAudioSession: jest.fn(), + showAudioRoutePicker: jest.fn(), + }, + PlatformConstants: { + forceTouchAvailable: false, + }, + ImageLoader: { + getSize: jest.fn((url) => Promise.resolve({ width: 0, height: 0 })), + prefetchImage: jest.fn(), + }, + SettingsManager: { + settings: { + AppleLocale: 'en_US', + AppleLanguages: ['en'], + }, + }, + }, }; }); @@ -402,6 +439,29 @@ jest.mock('livekit-client', () => ({ }, })); +jest.mock('@livekit/react-native', () => ({ + AudioSession: { + startAudioSession: jest.fn().mockResolvedValue(undefined), + stopAudioSession: jest.fn().mockResolvedValue(undefined), + configureAudio: jest.fn().mockResolvedValue(undefined), + }, + useRoom: jest.fn().mockReturnValue({ + room: null, + participants: [], + audioTracks: [], + }), + useParticipant: jest.fn().mockReturnValue({ + cameraPublication: null, + microphonePublication: null, + screenSharePublication: null, + }), + MediaStreamTrack: class {}, + mediaDevices: { + enumerateDevices: jest.fn().mockResolvedValue([]), + getUserMedia: jest.fn().mockResolvedValue({}), + }, +})); + jest.mock('react-native-permissions', () => ({ PERMISSIONS: { ANDROID: { diff --git a/package.json b/package.json index 79774ad..a35ae8d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "postinstall": "patch-package", "prebuild": "cross-env EXPO_NO_DOTENV=1 yarn expo prebuild", "android": "cross-env EXPO_NO_DOTENV=1 expo run:android", - "ios": "cross-env EXPO_NO_DOTENV=1 expo run:ios", + "ios": "cross-env EXPO_NO_DOTENV=1 expo run:ios --device", "web": "cross-env EXPO_NO_DOTENV=1 expo start --web", "xcode": "xed -b ios", "doctor": "npx expo-doctor@latest", diff --git a/src/app/(app)/__tests__/calendar.test.tsx b/src/app/(app)/__tests__/calendar.test.tsx index d7f3dea..ca310ce 100644 --- a/src/app/(app)/__tests__/calendar.test.tsx +++ b/src/app/(app)/__tests__/calendar.test.tsx @@ -273,6 +273,11 @@ describe('CalendarScreen', () => { }); jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); }); it('renders calendar screen correctly', () => { diff --git a/src/app/(app)/__tests__/map.test.tsx b/src/app/(app)/__tests__/map.test.tsx index 597c789..2866b42 100644 --- a/src/app/(app)/__tests__/map.test.tsx +++ b/src/app/(app)/__tests__/map.test.tsx @@ -315,6 +315,11 @@ describe('HomeMap', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/app/(app)/__tests__/messages.test.tsx b/src/app/(app)/__tests__/messages.test.tsx index aaf44e7..f47595e 100644 --- a/src/app/(app)/__tests__/messages.test.tsx +++ b/src/app/(app)/__tests__/messages.test.tsx @@ -447,6 +447,11 @@ describe('MessagesScreen', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockedUseMessagesStore.mockReturnValue(mockStore); mockedUseSecurityStore.mockReturnValue(mockSecurityStore); mockUseAnalytics.mockReturnValue({ diff --git a/src/app/(app)/__tests__/notes.test.tsx b/src/app/(app)/__tests__/notes.test.tsx index c39a139..00161aa 100644 --- a/src/app/(app)/__tests__/notes.test.tsx +++ b/src/app/(app)/__tests__/notes.test.tsx @@ -32,6 +32,11 @@ jest.mock('@/stores/notes/store', () => ({ describe('Notes Screen Analytics', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); }); it('tracks notes view analytics event with correct data', () => { diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/app/(app)/__tests__/protocols.test.tsx index d043b24..4f522a5 100644 --- a/src/app/(app)/__tests__/protocols.test.tsx +++ b/src/app/(app)/__tests__/protocols.test.tsx @@ -294,6 +294,11 @@ const mockProtocols: CallProtocolsResultData[] = [ describe('Protocols Page', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Reset mock store to default state Object.assign(mockProtocolsStore, { protocols: [], diff --git a/src/app/(app)/__tests__/settings.test.tsx b/src/app/(app)/__tests__/settings.test.tsx index a8daa14..0147ddd 100644 --- a/src/app/(app)/__tests__/settings.test.tsx +++ b/src/app/(app)/__tests__/settings.test.tsx @@ -301,6 +301,11 @@ describe('Settings Screen', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mocks mockUseColorScheme.mockReturnValue({ diff --git a/src/app/(app)/__tests__/shifts.test.tsx b/src/app/(app)/__tests__/shifts.test.tsx index 0b97600..251e9dc 100644 --- a/src/app/(app)/__tests__/shifts.test.tsx +++ b/src/app/(app)/__tests__/shifts.test.tsx @@ -347,6 +347,11 @@ describe('ShiftsScreen', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/app/(app)/__tests__/units.test.tsx b/src/app/(app)/__tests__/units.test.tsx index 4937bde..b96d6bc 100644 --- a/src/app/(app)/__tests__/units.test.tsx +++ b/src/app/(app)/__tests__/units.test.tsx @@ -257,6 +257,11 @@ describe('Units', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); Object.assign(mockUnitsStore, { units: [], searchQuery: '', diff --git a/src/app/(app)/home/__tests__/calls.test.tsx b/src/app/(app)/home/__tests__/calls.test.tsx index 38461cd..2b4e306 100644 --- a/src/app/(app)/home/__tests__/calls.test.tsx +++ b/src/app/(app)/home/__tests__/calls.test.tsx @@ -164,6 +164,12 @@ describe('Calls Screen', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/app/(app)/home/__tests__/index.test.tsx b/src/app/(app)/home/__tests__/index.test.tsx index 848da95..011062f 100644 --- a/src/app/(app)/home/__tests__/index.test.tsx +++ b/src/app/(app)/home/__tests__/index.test.tsx @@ -198,6 +198,12 @@ describe('HomeDashboard', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseHomeStore.mockReturnValue({ departmentStats: { diff --git a/src/app/__tests__/onboarding.test.tsx b/src/app/__tests__/onboarding.test.tsx index 5762016..8cb3d95 100644 --- a/src/app/__tests__/onboarding.test.tsx +++ b/src/app/__tests__/onboarding.test.tsx @@ -186,6 +186,10 @@ describe('Onboarding Component', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); (useRouter as jest.Mock).mockReturnValue(mockRouter); (useFocusEffect as jest.Mock).mockImplementation((callback) => callback()); diff --git a/src/app/login/__tests__/index.test.tsx b/src/app/login/__tests__/index.test.tsx index 0700031..5495d5b 100644 --- a/src/app/login/__tests__/index.test.tsx +++ b/src/app/login/__tests__/index.test.tsx @@ -43,6 +43,11 @@ jest.mock('@/lib/logging', () => ({ describe('Login Analytics Tests', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); }); it('should call trackEvent with login_viewed when useFocusEffect is triggered', () => { diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/app/login/__tests__/login-form.test.tsx index 91cebdb..29e6a13 100644 --- a/src/app/login/__tests__/login-form.test.tsx +++ b/src/app/login/__tests__/login-form.test.tsx @@ -201,6 +201,11 @@ describe('LoginForm Server URL Integration', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockTrackEvent.mockClear(); mockServerUrlBottomSheet.mockClear(); }); diff --git a/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx b/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx index e43bd0c..d739f59 100644 --- a/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx +++ b/src/components/audio-stream/__tests__/audio-stream-bottom-sheet.test.tsx @@ -129,6 +129,11 @@ import { AudioStreamBottomSheet } from '../audio-stream-bottom-sheet'; describe('AudioStreamBottomSheet Analytics', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); }); describe('Analytics Hook Integration', () => { diff --git a/src/components/bluetooth/__tests__/bluetooth-audio-modal.test.tsx b/src/components/bluetooth/__tests__/bluetooth-audio-modal.test.tsx index 6564976..dbdb0e8 100644 --- a/src/components/bluetooth/__tests__/bluetooth-audio-modal.test.tsx +++ b/src/components/bluetooth/__tests__/bluetooth-audio-modal.test.tsx @@ -188,6 +188,11 @@ describe('BluetoothAudioModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockDateNow.mockReturnValue(1642248000000); // Reset to default timestamp // Mock analytics diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx index 969121a..14373f5 100644 --- a/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx +++ b/src/components/calendar/__tests__/calendar-item-details-sheet-analytics.test.tsx @@ -206,6 +206,11 @@ describe('CalendarItemDetailsSheet Analytics', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseTranslation.mockReturnValue({ t: mockT, diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx index 5d0ff48..2328cab 100644 --- a/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx +++ b/src/components/calendar/__tests__/calendar-item-details-sheet-minimal.test.tsx @@ -132,6 +132,11 @@ describe('CalendarItemDetailsSheet - Analytics Only', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); (useAnalytics as jest.Mock).mockReturnValue({ trackEvent: mockTrackEvent, }); diff --git a/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx b/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx index a74e5c8..486a455 100644 --- a/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx +++ b/src/components/calendar/__tests__/calendar-item-details-sheet.test.tsx @@ -241,6 +241,11 @@ describe('CalendarItemDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseTranslation.mockReturnValue({ t: mockT, diff --git a/src/components/calls/__tests__/call-detail-menu-integration.test.tsx b/src/components/calls/__tests__/call-detail-menu-integration.test.tsx index 59c24fb..6d84107 100644 --- a/src/components/calls/__tests__/call-detail-menu-integration.test.tsx +++ b/src/components/calls/__tests__/call-detail-menu-integration.test.tsx @@ -98,6 +98,11 @@ describe('Call Detail Menu Integration Test', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics useAnalytics.mockReturnValue({ diff --git a/src/components/calls/__tests__/call-detail-menu.test.tsx b/src/components/calls/__tests__/call-detail-menu.test.tsx index 55e9f64..27b84a1 100644 --- a/src/components/calls/__tests__/call-detail-menu.test.tsx +++ b/src/components/calls/__tests__/call-detail-menu.test.tsx @@ -114,6 +114,11 @@ describe('useCallDetailMenu', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics useAnalytics.mockReturnValue({ diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx index 61ede12..6ea88a8 100644 --- a/src/components/calls/__tests__/call-files-modal.test.tsx +++ b/src/components/calls/__tests__/call-files-modal.test.tsx @@ -305,6 +305,11 @@ describe('CallFilesModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Reset to default state mockStoreState = { callFiles: defaultMockFiles, diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx index 127821f..8cea606 100644 --- a/src/components/calls/__tests__/call-images-modal.test.tsx +++ b/src/components/calls/__tests__/call-images-modal.test.tsx @@ -350,6 +350,12 @@ describe('CallImagesModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseCallDetailStore.mockReturnValue(mockStore as any); mockUseAuthStore.getState.mockReturnValue({ userId: 'test-user-id', diff --git a/src/components/calls/__tests__/call-notes-modal.test.tsx b/src/components/calls/__tests__/call-notes-modal.test.tsx index 10ec213..a857d8e 100644 --- a/src/components/calls/__tests__/call-notes-modal.test.tsx +++ b/src/components/calls/__tests__/call-notes-modal.test.tsx @@ -146,6 +146,11 @@ describe('CallNotesModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseTranslation.mockReturnValue({ t: (key: string) => { diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx index b1edf4d..cc69a55 100644 --- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx +++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx @@ -207,6 +207,11 @@ const mockUseToastStore = useToastStore as jest.MockedFunction { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Clear the console.error mock as well (console.error as jest.Mock).mockClear(); diff --git a/src/components/contacts/__tests__/contact-details-sheet.test.tsx b/src/components/contacts/__tests__/contact-details-sheet.test.tsx index cc5bf5d..f31e819 100644 --- a/src/components/contacts/__tests__/contact-details-sheet.test.tsx +++ b/src/components/contacts/__tests__/contact-details-sheet.test.tsx @@ -325,6 +325,11 @@ describe('ContactDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/components/messages/__tests__/compose-message-sheet-simple.test.tsx b/src/components/messages/__tests__/compose-message-sheet-simple.test.tsx index 18468b0..b13baa9 100644 --- a/src/components/messages/__tests__/compose-message-sheet-simple.test.tsx +++ b/src/components/messages/__tests__/compose-message-sheet-simple.test.tsx @@ -194,6 +194,11 @@ describe('ComposeMessageSheet Analytics', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseAnalytics.mockReturnValue({ trackEvent: mockTrackEvent, diff --git a/src/components/messages/__tests__/compose-message-sheet.test.tsx b/src/components/messages/__tests__/compose-message-sheet.test.tsx index 32f1e19..4920258 100644 --- a/src/components/messages/__tests__/compose-message-sheet.test.tsx +++ b/src/components/messages/__tests__/compose-message-sheet.test.tsx @@ -369,6 +369,11 @@ describe('ComposeMessageSheet Analytics', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/components/messages/__tests__/message-details-sheet.test.tsx b/src/components/messages/__tests__/message-details-sheet.test.tsx index 6480012..16f5aa3 100644 --- a/src/components/messages/__tests__/message-details-sheet.test.tsx +++ b/src/components/messages/__tests__/message-details-sheet.test.tsx @@ -332,6 +332,11 @@ describe('MessageDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/components/notes/__tests__/note-details-sheet-analytics.test.tsx b/src/components/notes/__tests__/note-details-sheet-analytics.test.tsx index fcd3849..f94af38 100644 --- a/src/components/notes/__tests__/note-details-sheet-analytics.test.tsx +++ b/src/components/notes/__tests__/note-details-sheet-analytics.test.tsx @@ -44,6 +44,11 @@ describe('NoteDetailsSheet Analytics', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Reset mock store mockStore.notes = []; mockStore.selectedNoteId = null; diff --git a/src/components/notes/__tests__/note-details-sheet-integration.test.tsx b/src/components/notes/__tests__/note-details-sheet-integration.test.tsx index ec13d5b..5ecb548 100644 --- a/src/components/notes/__tests__/note-details-sheet-integration.test.tsx +++ b/src/components/notes/__tests__/note-details-sheet-integration.test.tsx @@ -46,6 +46,11 @@ describe('NoteDetailsSheet Integration', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Reset mock store mockStoreState = { notes: [], diff --git a/src/components/notes/__tests__/note-details-sheet.test.tsx b/src/components/notes/__tests__/note-details-sheet.test.tsx index 18ecc1c..43c3b7a 100644 --- a/src/components/notes/__tests__/note-details-sheet.test.tsx +++ b/src/components/notes/__tests__/note-details-sheet.test.tsx @@ -67,15 +67,15 @@ jest.mock('../../ui/actionsheet', () => { const React = require('react'); const { View } = require('react-native'); return { - Actionsheet: ({ children, testID, ...props }: any) => + Actionsheet: ({ children, testID, ...props }: any) => React.createElement(View, { testID, ...props }, children), - ActionsheetBackdrop: ({ children, ...props }: any) => + ActionsheetBackdrop: ({ children, ...props }: any) => React.createElement(View, { ...props }, children), - ActionsheetContent: ({ children, ...props }: any) => + ActionsheetContent: ({ children, ...props }: any) => React.createElement(View, { ...props }, children), - ActionsheetDragIndicator: ({ children, ...props }: any) => + ActionsheetDragIndicator: ({ children, ...props }: any) => React.createElement(View, { ...props }, children), - ActionsheetDragIndicatorWrapper: ({ children, ...props }: any) => + ActionsheetDragIndicatorWrapper: ({ children, ...props }: any) => React.createElement(View, { ...props }, children), }; }); @@ -84,7 +84,7 @@ jest.mock('../../ui/box', () => { const React = require('react'); const { View } = require('react-native'); return { - Box: ({ children, ...props }: any) => + Box: ({ children, ...props }: any) => React.createElement(View, { ...props }, children), }; }); @@ -93,7 +93,7 @@ jest.mock('../../ui/button', () => { const React = require('react'); const { TouchableOpacity } = require('react-native'); return { - Button: ({ children, testID, onPress, ...props }: any) => + Button: ({ children, testID, onPress, ...props }: any) => React.createElement(TouchableOpacity, { testID, onPress, ...props }, children), }; }); @@ -102,7 +102,7 @@ jest.mock('../../ui/divider', () => { const React = require('react'); const { View } = require('react-native'); return { - Divider: ({ ...props }: any) => + Divider: ({ ...props }: any) => React.createElement(View, { ...props }), }; }); @@ -111,7 +111,7 @@ jest.mock('../../ui/heading', () => { const React = require('react'); const { Text } = require('react-native'); return { - Heading: ({ children, ...props }: any) => + Heading: ({ children, ...props }: any) => React.createElement(Text, { ...props }, children), }; }); @@ -120,7 +120,7 @@ jest.mock('../../ui/hstack', () => { const React = require('react'); const { View } = require('react-native'); return { - HStack: ({ children, ...props }: any) => + HStack: ({ children, ...props }: any) => React.createElement(View, { style: { flexDirection: 'row' }, ...props }, children), }; }); @@ -129,7 +129,7 @@ jest.mock('../../ui/text', () => { const React = require('react'); const { Text: RNText } = require('react-native'); return { - Text: ({ children, ...props }: any) => + Text: ({ children, ...props }: any) => React.createElement(RNText, { ...props }, children), }; }); @@ -138,7 +138,7 @@ jest.mock('../../ui/vstack', () => { const React = require('react'); const { View } = require('react-native'); return { - VStack: ({ children, ...props }: any) => + VStack: ({ children, ...props }: any) => React.createElement(View, { style: { flexDirection: 'column' }, ...props }, children), }; }); @@ -164,11 +164,17 @@ describe('NoteDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Mock AppState to prevent MMKV issues const { AppState } = require('react-native'); jest.spyOn(AppState, 'addEventListener').mockImplementation(() => ({}) as any); - jest.spyOn(AppState, 'removeEventListener').mockImplementation(() => {}); + jest.spyOn(AppState, 'removeEventListener').mockImplementation(() => { }); mockUseTranslation.mockReturnValue({ t: (key: string) => key, diff --git a/src/components/personnel/__tests__/personnel-details-sheet.test.tsx b/src/components/personnel/__tests__/personnel-details-sheet.test.tsx index e16b02c..de05194 100644 --- a/src/components/personnel/__tests__/personnel-details-sheet.test.tsx +++ b/src/components/personnel/__tests__/personnel-details-sheet.test.tsx @@ -160,6 +160,11 @@ describe('PersonnelDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockPersonnelStore.personnel = []; mockPersonnelStore.selectedPersonnelId = null; mockPersonnelStore.isDetailsOpen = false; diff --git a/src/components/personnel/__tests__/personnel-filter-sheet.test.tsx b/src/components/personnel/__tests__/personnel-filter-sheet.test.tsx index dd0b67b..6ce576d 100644 --- a/src/components/personnel/__tests__/personnel-filter-sheet.test.tsx +++ b/src/components/personnel/__tests__/personnel-filter-sheet.test.tsx @@ -97,6 +97,11 @@ describe('PersonnelFilterSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseAnalytics.mockReturnValue({ trackEvent: mockTrackEvent, }); diff --git a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx index c9d4432..594fafe 100644 --- a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx @@ -173,6 +173,11 @@ describe('LoginInfoBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ @@ -249,6 +254,11 @@ describe('LoginInfoBottomSheet', () => { describe('Analytics Integration', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); }); it('tracks analytics when sheet becomes visible', () => { diff --git a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx index 553a6af..88fc5cf 100644 --- a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx @@ -167,6 +167,11 @@ describe('ServerUrlBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx index 12d2edc..4b631ab 100644 --- a/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx +++ b/src/components/settings/bluetooth-device-selection-bottom-sheet.tsx @@ -133,6 +133,32 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo currentConnectedDeviceId: connectedDevice?.id || '', }); + // Special handling for System Audio / Airpods "virtual" device + if (device.id === 'system-audio') { + // Just set it as preferred, no actual BLE connection needed + const selectedDevice = { + id: device.id, + name: device.name || t('bluetooth.system_audio', 'System Audio / Airpods'), + }; + + await setPreferredDevice(selectedDevice); + + // If we had a BLE device connected, disconnect it + if (connectedDevice && connectedDevice.id !== 'system-audio') { + try { + await bluetoothAudioService.disconnectDevice(); + } catch (e) { + logger.warn({ message: 'Failed to disconnect previous BLE device', context: { error: e } }); + } + } + + onClose(); + setConnectingDeviceId(null); + return; + } + + // --- Standard BLE Device Logic Below --- + // First, clear any existing preferred device await setPreferredDevice(null); @@ -298,7 +324,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo {isConnecting ? ( - + ) : ( @@ -379,14 +405,7 @@ export function BluetoothDeviceSelectionBottomSheet({ isOpen, onClose }: Bluetoo {/* Device List */} - item.id} - ListEmptyComponent={renderEmptyState} - showsVerticalScrollIndicator={false} - estimatedItemSize={94} - /> + item.id} ListEmptyComponent={renderEmptyState} showsVerticalScrollIndicator={false} estimatedItemSize={94} /> {/* Bluetooth State Info */} diff --git a/src/components/shifts/__tests__/shift-details-sheet.test.tsx b/src/components/shifts/__tests__/shift-details-sheet.test.tsx index e441e36..71ce1a0 100644 --- a/src/components/shifts/__tests__/shift-details-sheet.test.tsx +++ b/src/components/shifts/__tests__/shift-details-sheet.test.tsx @@ -273,6 +273,12 @@ describe('ShiftDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseShiftsStore.mockReturnValue(defaultStoreState); mockTrackEvent.mockClear(); diff --git a/src/components/staffing/__tests__/staffing-bottom-sheet.test.tsx b/src/components/staffing/__tests__/staffing-bottom-sheet.test.tsx index 8b26ea5..104a950 100644 --- a/src/components/staffing/__tests__/staffing-bottom-sheet.test.tsx +++ b/src/components/staffing/__tests__/staffing-bottom-sheet.test.tsx @@ -253,6 +253,11 @@ describe('StaffingBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseCoreStore.mockReturnValue({ activeStaffing: mockActiveStaffing, diff --git a/src/components/status/__tests__/personnel-status-analytics.test.tsx b/src/components/status/__tests__/personnel-status-analytics.test.tsx index 6e3526b..726f275 100644 --- a/src/components/status/__tests__/personnel-status-analytics.test.tsx +++ b/src/components/status/__tests__/personnel-status-analytics.test.tsx @@ -48,6 +48,11 @@ describe('Analytics Integration Test', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseAnalytics.mockReturnValue({ trackEvent: mockTrackEvent, }); diff --git a/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx b/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx index adf9c9a..1233456 100644 --- a/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/personnel-status-bottom-sheet.test.tsx @@ -289,6 +289,11 @@ describe('PersonnelStatusBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/components/status/__tests__/personnel-status-integration.test.tsx b/src/components/status/__tests__/personnel-status-integration.test.tsx index fea6937..f5d2746 100644 --- a/src/components/status/__tests__/personnel-status-integration.test.tsx +++ b/src/components/status/__tests__/personnel-status-integration.test.tsx @@ -230,6 +230,11 @@ describe('PersonnelStatusBottomSheet Integration Tests', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); mockUseAnalytics.mockReturnValue({ trackEvent: mockTrackEvent, diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx index 6c94db1..84cd299 100644 --- a/src/components/ui/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet.tsx @@ -29,7 +29,7 @@ export function CustomBottomSheet({ children, isOpen, onClose, isLoading = false - + {isLoading ? (
diff --git a/src/components/units/__tests__/unit-details-sheet.test.tsx b/src/components/units/__tests__/unit-details-sheet.test.tsx index af2d606..00f812b 100644 --- a/src/components/units/__tests__/unit-details-sheet.test.tsx +++ b/src/components/units/__tests__/unit-details-sheet.test.tsx @@ -206,6 +206,11 @@ const mockUnitMinimal: UnitResultData = { describe('UnitDetailsSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); Object.assign(mockUnitsStore, { units: [], selectedUnitId: null, diff --git a/src/components/units/__tests__/units-filter-sheet.test.tsx b/src/components/units/__tests__/units-filter-sheet.test.tsx index 9fd0cd4..61d3579 100644 --- a/src/components/units/__tests__/units-filter-sheet.test.tsx +++ b/src/components/units/__tests__/units-filter-sheet.test.tsx @@ -118,6 +118,11 @@ jest.mock('react-native', () => ({ describe('UnitsFilterSheet', () => { beforeEach(() => { jest.clearAllMocks(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); + mockTrackEvent.mockReset(); // Default mock for analytics mockUseAnalytics.mockReturnValue({ diff --git a/src/features/livekit-call/components/LiveKitCallModal.tsx b/src/features/livekit-call/components/LiveKitCallModal.tsx deleted file mode 100644 index 4ab492a..0000000 --- a/src/features/livekit-call/components/LiveKitCallModal.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { AlertTriangle, CheckCircle, CircleIcon, Mic, MicOff, PhoneMissed } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; -import { ScrollView } from 'react-native'; - -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; -import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; -import { Heading } from '@/components/ui/heading'; -import { HStack } from '@/components/ui/hstack'; -import { Radio, RadioGroup, RadioIndicator, RadioLabel } from '@/components/ui/radio'; -import { Spinner } from '@/components/ui/spinner'; -import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; - -import { type RoomInfo, useLiveKitCallStore } from '../store/useLiveKitCallStore'; - -interface LiveKitCallModalProps { - isOpen: boolean; - onClose: () => void; - participantIdentity?: string; // Optional: pass if you have a specific identity -} - -const LiveKitCallModal: React.FC = ({ - isOpen, - onClose, - participantIdentity = `user-${Math.random().toString(36).substring(7)}`, // Default unique enough for example -}) => { - const { availableRooms, selectedRoomForJoining, currentRoomId, isConnecting, isConnected, error, localParticipant, actions } = useLiveKitCallStore(); - - const [isMicrophoneEnabled, setIsMicrophoneEnabled] = useState(true); - - useEffect(() => { - if (localParticipant) { - const micPublication = localParticipant.getTrackPublicationByName('microphone'); - setIsMicrophoneEnabled(micPublication ? micPublication.isMuted === false : true); - } else { - setIsMicrophoneEnabled(true); // Default before connected - } - }, [localParticipant, isConnected]); - - const handleJoinRoom = () => { - if (selectedRoomForJoining && !isConnecting && !isConnected) { - actions.connectToRoom(selectedRoomForJoining, participantIdentity); - // Modal can be closed by user, connection persists via store - } - }; - - const handleLeaveRoom = () => { - actions.disconnectFromRoom(); - onClose(); // Close modal on leaving - }; - - const handleToggleMicrophone = async () => { - await actions.setMicrophoneEnabled(!isMicrophoneEnabled); - setIsMicrophoneEnabled(!isMicrophoneEnabled); // Update local state immediately for UI responsiveness - }; - - const internalOnClose = () => { - if (isConnecting) { - // Optionally prevent closing or ask for confirmation if connecting - // For now, allow close - } - actions._clearError(); // Clear any transient errors when modal is closed - onClose(); - }; - - const currentRoomName = availableRooms.find((r) => r.id === currentRoomId)?.name || currentRoomId; - const selectedRoomName = availableRooms.find((r) => r.id === selectedRoomForJoining)?.name || selectedRoomForJoining; - - return ( - - - - - - - - {isConnecting ? ( - - - Connecting to {selectedRoomName || 'room'}... - - ) : error ? ( - - - - Connection Error - - {error} - - - ) : isConnected && currentRoomId ? ( - - - - - Connected - - - You are in room: {currentRoomName} - - {localParticipant && Your ID: {localParticipant.identity}} - - - - - - - - ) : ( - - - Join a Voice Call - - Select a room to join: - - actions.setSelectedRoomForJoining(nextValue)} accessibilityLabel="Select a room"> - - {availableRooms.map((room: RoomInfo) => ( - - - - - {room.name} - - ))} - - - - - - )} - - - ); -}; - -export default LiveKitCallModal; diff --git a/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts b/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts deleted file mode 100644 index 1004982..0000000 --- a/src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import { Platform } from 'react-native'; -import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; -import { renderHook, act } from '@testing-library/react-native'; - -// Mock Platform -const mockPlatform = Platform as jest.Mocked; - -// Mock the CallKeep service module -jest.mock('../../../../services/callkeep.service.ios', () => ({ - callKeepService: { - setup: jest.fn(), - startCall: jest.fn(), - endCall: jest.fn(), - isCallActiveNow: jest.fn(), - getCurrentCallUUID: jest.fn(), - cleanup: jest.fn(), - setMuteStateCallback: jest.fn(), - }, -})); - -// Mock logger -jest.mock('../../../../lib/logging', () => ({ - logger: { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -// Mock livekit-client -const mockRoom = { - on: jest.fn(), - connect: jest.fn(), - disconnect: jest.fn(), - localParticipant: { - setMicrophoneEnabled: jest.fn(), - setCameraEnabled: jest.fn(), - identity: 'local-participant', - }, - remoteParticipants: new Map(), - name: 'test-room', -} as any; - -jest.mock('livekit-client', () => ({ - Room: jest.fn().mockImplementation(() => mockRoom), - RoomEvent: { - ConnectionStateChanged: 'connectionStateChanged', - ParticipantConnected: 'participantConnected', - ParticipantDisconnected: 'participantDisconnected', - }, - ConnectionState: { - Connected: 'connected', - Disconnected: 'disconnected', - Connecting: 'connecting', - Reconnecting: 'reconnecting', - }, -})); - -import { useLiveKitCallStore } from '../useLiveKitCallStore'; -import { logger } from '../../../../lib/logging'; - -// Get the mocked constructors after the imports -const mockCallKeepService = require('../../../../services/callkeep.service.ios').callKeepService; -const mockLogger = logger as jest.Mocked; -const MockedRoom = require('livekit-client').Room as jest.MockedClass; - -describe('useLiveKitCallStore with CallKeep Integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockPlatform.OS = 'ios'; - - // Reset mock implementations - mockCallKeepService.setup.mockResolvedValue(undefined); - mockCallKeepService.startCall.mockResolvedValue('test-uuid'); - mockCallKeepService.endCall.mockResolvedValue(undefined); - mockCallKeepService.isCallActiveNow.mockReturnValue(false); - mockCallKeepService.getCurrentCallUUID.mockReturnValue(null); - mockCallKeepService.setMuteStateCallback.mockReturnValue(undefined); - - // Reset the Room mock to return mockRoom by default - MockedRoom.mockImplementation(() => mockRoom); - - mockRoom.connect.mockResolvedValue(undefined); - mockRoom.disconnect.mockResolvedValue(undefined); - mockRoom.localParticipant.setMicrophoneEnabled.mockResolvedValue(undefined); - mockRoom.localParticipant.setCameraEnabled.mockResolvedValue(undefined); - - // Clear logger mocks - mockLogger.debug.mockClear(); - mockLogger.info.mockClear(); - mockLogger.warn.mockClear(); - mockLogger.error.mockClear(); - - // Reset the store to initial state - useLiveKitCallStore.setState({ - availableRooms: [ - { id: 'general-chat', name: 'General Chat' }, - { id: 'dev-team-sync', name: 'Dev Team Sync' }, - { id: 'product-updates', name: 'Product Updates' }, - ], - selectedRoomForJoining: null, - currentRoomId: null, - isConnecting: false, - isConnected: false, - roomInstance: null, - participants: [], - error: null, - localParticipant: null, - }); - }); - - describe('CallKeep Mute State Callback', () => { - beforeEach(() => { - mockPlatform.OS = 'ios'; - }); - - it('should register mute state callback when connecting on iOS', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockCallKeepService.setMuteStateCallback).toHaveBeenCalledWith(expect.any(Function)); - }); - - it('should clear mute state callback when disconnecting on iOS', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockCallKeepService.setMuteStateCallback).toHaveBeenCalledWith(null); - }); - - it('should not register callback on non-iOS platforms', async () => { - mockPlatform.OS = 'android'; - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockCallKeepService.setMuteStateCallback).not.toHaveBeenCalled(); - }); - }); - - describe('Room Connection with CallKeep', () => { - beforeEach(() => { - // Mock successful connection flow with proper async handling - mockRoom.on.mockImplementation((event: any, callback: any) => { - if (event === 'connectionStateChanged') { - // Store the callback for manual triggering - (mockRoom as any)._connectionStateCallback = callback; - } - return mockRoom; - }); - }); - - it('should start CallKeep call on successful room connection (iOS)', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('general-chat', 'test-participant'); - - // Manually trigger the connection state change - if ((mockRoom as any)._connectionStateCallback) { - (mockRoom as any)._connectionStateCallback('connected'); - } - }); - - expect(mockCallKeepService.startCall).toHaveBeenCalledWith('general-chat'); - }); - - it('should not start CallKeep call on Android', async () => { - mockPlatform.OS = 'android'; - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('dev-team-sync', 'test-participant'); - - // Manually trigger the connection state change - if ((mockRoom as any)._connectionStateCallback) { - (mockRoom as any)._connectionStateCallback('connected'); - } - }); - - expect(mockCallKeepService.startCall).not.toHaveBeenCalled(); - }); - - it('should handle CallKeep start call errors gracefully', async () => { - const error = new Error('Failed to start call'); - mockCallKeepService.startCall.mockRejectedValueOnce(error); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('general-chat', 'test-participant'); - - // Manually trigger the connection state change - if ((mockRoom as any)._connectionStateCallback) { - (mockRoom as any)._connectionStateCallback('connected'); - } - }); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Failed to start CallKeep call (background audio may not work)', - context: { error, roomId: 'general-chat' }, - }); - }); - }); - - describe('Room Disconnection with CallKeep', () => { - it('should end CallKeep call on room disconnection (iOS)', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // First set up a connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).toHaveBeenCalled(); - expect(mockCallKeepService.endCall).toHaveBeenCalled(); - }); - - it('should not end CallKeep call on Android', async () => { - mockPlatform.OS = 'android'; - const { result } = renderHook(() => useLiveKitCallStore()); - - // First set up a connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).toHaveBeenCalled(); - expect(mockCallKeepService.endCall).not.toHaveBeenCalled(); - }); - - it('should handle CallKeep end call errors gracefully', async () => { - const error = new Error('Failed to end call'); - mockCallKeepService.endCall.mockRejectedValueOnce(error); - - const { result } = renderHook(() => useLiveKitCallStore()); - - // First set up a connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).toHaveBeenCalled(); - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Failed to end CallKeep call', - context: { error }, - }); - }); - - it('should handle disconnection when no room instance exists', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.disconnectFromRoom(); - }); - - expect(mockRoom.disconnect).not.toHaveBeenCalled(); - expect(mockCallKeepService.endCall).not.toHaveBeenCalled(); - }); - }); - - describe('Connection State Changes with CallKeep', () => { - it('should end CallKeep call on connection lost (iOS)', async () => { - mockPlatform.OS = 'ios'; - - // Mock the room event listener - let connectionStateListener: Function | null = null; - mockRoom.on.mockImplementation((event: any, callback: any) => { - if (event === 'connectionStateChanged') { - connectionStateListener = callback; - } - return mockRoom; - }); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(connectionStateListener).toBeDefined(); - - // Simulate disconnection - if (connectionStateListener) { - act(() => { - connectionStateListener!('disconnected'); - }); - } - - expect(mockCallKeepService.endCall).toHaveBeenCalled(); - }); - - it('should not end CallKeep call on Android disconnection', async () => { - mockPlatform.OS = 'android'; - - // Mock the room event listener - let connectionStateListener: Function | null = null; - mockRoom.on.mockImplementation((event: any, callback: any) => { - if (event === 'connectionStateChanged') { - connectionStateListener = callback; - } - return mockRoom; - }); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(connectionStateListener).toBeDefined(); - - // Simulate disconnection - if (connectionStateListener) { - act(() => { - connectionStateListener!('disconnected'); - }); - } - - expect(mockCallKeepService.endCall).not.toHaveBeenCalled(); - }); - }); - - describe('Store State Management', () => { - it('should initialize with correct default state', () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - expect(result.current.availableRooms).toHaveLength(3); - expect(result.current.availableRooms[0]).toEqual({ id: 'general-chat', name: 'General Chat' }); - expect(result.current.selectedRoomForJoining).toBeNull(); - expect(result.current.currentRoomId).toBeNull(); - expect(result.current.isConnecting).toBe(false); - expect(result.current.isConnected).toBe(false); - expect(result.current.roomInstance).toBeNull(); - expect(result.current.participants).toEqual([]); - expect(result.current.error).toBeNull(); - expect(result.current.localParticipant).toBeNull(); - }); - - it('should clear error when setting selected room', () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - act(() => { - result.current.actions.setSelectedRoomForJoining('test-room'); - }); - - expect(result.current.selectedRoomForJoining).toBe('test-room'); - expect(result.current.error).toBeNull(); - }); - - it('should clear error explicitly', () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - act(() => { - result.current.actions._clearError(); - }); - - expect(result.current.error).toBeNull(); - }); - }); - - describe('Microphone Control', () => { - it('should enable microphone when connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(true); - }); - - expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true); - }); - - it('should disable microphone when connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(false); - }); - - expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false); - }); - - it('should handle microphone errors', async () => { - const error = new Error('Microphone error'); - mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(error); - - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set up connected state - act(() => { - result.current.actions._setRoomInstance(mockRoom); - result.current.actions._setIsConnected(true); - }); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(true); - }); - - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Error setting microphone state', - context: { error, enabled: true }, - }); - - // Check that error state was set after the await completes - expect(result.current.error).toBe('Could not change microphone state.'); - }); - - it('should handle microphone control when not connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.setMicrophoneEnabled(true); - }); - - expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled(); - }); - }); - - describe('Connection Prevention', () => { - it('should prevent connection when already connecting', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set connecting state - act(() => { - result.current.actions._setIsConnecting(true); - }); - - // First connection attempt should succeed - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - // Second connection attempt should be prevented - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Connection attempt while already connecting or connected', - context: { - roomId: 'test-room', - participantIdentity: 'test-participant', - isConnecting: true, - isConnected: false - }, - }); - }); - - it('should prevent connection when already connected', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Set connected state - act(() => { - result.current.actions._setIsConnected(true); - result.current.actions._setRoomInstance(mockRoom); - }); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockLogger.warn).toHaveBeenCalledWith({ - message: 'Connection attempt while already connecting or connected', - context: { - roomId: 'test-room', - participantIdentity: 'test-participant', - isConnecting: false, - isConnected: true - }, - }); - }); - }); - - describe('Error Handling', () => { - it('should handle room initialization errors', async () => { - // Make the Room constructor throw an error - MockedRoom.mockImplementationOnce(() => { - throw new Error('Failed to initialize room'); - }); - - const { result } = renderHook(() => useLiveKitCallStore()); - - await act(async () => { - await result.current.actions.connectToRoom('test-room', 'test-participant'); - }); - - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Failed to connect to LiveKit room', - context: { error: expect.any(Error), roomId: 'test-room', participantIdentity: 'test-participant' }, - }); - expect(result.current.error).toBe('Failed to initialize room'); - expect(result.current.isConnecting).toBe(false); - expect(result.current.isConnected).toBe(false); - }); - - it('should handle basic error state management', async () => { - const { result } = renderHook(() => useLiveKitCallStore()); - - // Test basic error clearing functionality since token fetching isn't implemented - act(() => { - // Set an error state and then clear it - result.current.actions._clearError(); - }); - - expect(result.current.error).toBeNull(); - }); - }); -}); diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts deleted file mode 100644 index cf318cf..0000000 --- a/src/features/livekit-call/store/useLiveKitCallStore.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { ConnectionState, type LocalParticipant, type Participant, type RemoteParticipant, Room, type RoomConnectOptions, RoomEvent, type RoomOptions } from 'livekit-client'; // livekit-react-native re-exports these -import { Platform } from 'react-native'; -import { create } from 'zustand'; - -import { logger } from '../../../lib/logging'; -import { callKeepService } from '../../../services/callkeep.service.ios'; -import { inCallAudio } from '../../../utils/InCallAudio'; - -export interface RoomInfo { - id: string; - name: string; -} - -interface LiveKitCallState { - availableRooms: RoomInfo[]; - selectedRoomForJoining: string | null; - currentRoomId: string | null; - isConnecting: boolean; - isConnected: boolean; - roomInstance: Room | null; - participants: Participant[]; // Includes local participant - error: string | null; - localParticipant: LocalParticipant | null; - - actions: { - setSelectedRoomForJoining: (roomId: string | null) => void; - connectToRoom: (roomId: string, participantIdentity: string) => Promise; - disconnectFromRoom: () => Promise; - setMicrophoneEnabled: (enabled: boolean) => Promise; - // Internal actions - not typically called directly from UI - _setRoomInstance: (room: Room | null) => void; - _setIsConnected: (isConnected: boolean) => void; - _setIsConnecting: (isConnecting: boolean) => void; - _addParticipant: (participant: Participant) => void; - _removeParticipant: (participantId: string) => void; - _updateParticipants: () => void; - _clearError: () => void; - }; -} - -const initialRooms: RoomInfo[] = [ - { id: 'general-chat', name: 'General Chat' }, - { id: 'dev-team-sync', name: 'Dev Team Sync' }, - { id: 'product-updates', name: 'Product Updates' }, -]; - -export const useLiveKitCallStore = create((set, get) => ({ - availableRooms: initialRooms, - selectedRoomForJoining: null, - currentRoomId: null, - isConnecting: false, - isConnected: false, - roomInstance: null, - participants: [], - error: null, - localParticipant: null, - - actions: { - setSelectedRoomForJoining: (roomId) => set({ selectedRoomForJoining: roomId, error: null }), - _clearError: () => set({ error: null }), - - connectToRoom: async (roomId, participantIdentity) => { - if (get().isConnecting || get().isConnected) { - logger.warn({ - message: 'Connection attempt while already connecting or connected', - context: { roomId, participantIdentity, isConnecting: get().isConnecting, isConnected: get().isConnected }, - }); - return; - } - - set({ isConnecting: true, error: null, selectedRoomForJoining: roomId }); - - // Register CallKeep mute callback for iOS - if (Platform.OS === 'ios') { - callKeepService.setMuteStateCallback((muted: boolean) => { - const currentState = get(); - if (currentState.isConnected && currentState.roomInstance) { - currentState.actions.setMicrophoneEnabled(!muted); - } - }); - } - - try { - const roomOptions: RoomOptions = { - adaptiveStream: true, - dynacast: true, // Enable dynamic simulcast - }; - const newRoom = new Room(roomOptions); - - newRoom - .on(RoomEvent.ConnectionStateChanged, (state: ConnectionState) => { - logger.info({ - message: 'LiveKit Connection State Changed', - context: { state, roomId }, - }); - if (state === ConnectionState.Connected) { - set({ - isConnected: true, - isConnecting: false, - currentRoomId: roomId, - roomInstance: newRoom, - localParticipant: newRoom.localParticipant, - error: null, - }); - get().actions._updateParticipants(); // Initial participant list - newRoom.localParticipant.setMicrophoneEnabled(true); - newRoom.localParticipant.setCameraEnabled(false); // No video - - inCallAudio.playSound('connected'); - - // Start CallKeep call for iOS background audio support - if (Platform.OS === 'ios') { - callKeepService - .startCall(roomId) - .then((callUUID) => { - logger.info({ - message: 'CallKeep call started successfully', - context: { callUUID, roomId }, - }); - }) - .catch((error) => { - logger.warn({ - message: 'Failed to start CallKeep call (background audio may not work)', - context: { error, roomId }, - }); - }); - } - } else if (state === ConnectionState.Disconnected) { - set({ - isConnected: false, - isConnecting: false, - currentRoomId: null, - roomInstance: null, - participants: [], - localParticipant: null, - // Keep error if there was one leading to disconnect - }); - - inCallAudio.playSound('disconnected'); - - // End CallKeep call for iOS when disconnected - if (Platform.OS === 'ios') { - callKeepService - .endCall() - .then(() => { - logger.info({ - message: 'CallKeep call ended on disconnect', - context: { roomId }, - }); - }) - .catch((error) => { - logger.warn({ - message: 'Failed to end CallKeep call on disconnect', - context: { error, roomId }, - }); - }); - } - } else if (state === ConnectionState.Connecting) { - set({ isConnecting: true }); - } else if (state === ConnectionState.Reconnecting) { - set({ isConnecting: true, error: 'Connection lost, attempting to reconnect...' }); - } - }) - .on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => { - logger.info({ - message: 'Participant connected', - context: { participantIdentity: participant.identity, roomId }, - }); - get().actions._addParticipant(participant); - }) - .on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => { - logger.info({ - message: 'Participant disconnected', - context: { participantIdentity: participant.identity, roomId }, - }); - get().actions._removeParticipant(participant.sid); - }) - .on(RoomEvent.LocalTrackPublished, (trackPublication, participant) => { - logger.debug({ - message: 'Local track published', - context: { trackKind: trackPublication.kind, participantIdentity: participant.identity, roomId }, - }); - get().actions._updateParticipants(); // Ensure local participant updates reflect - }) - .on(RoomEvent.LocalTrackUnpublished, (trackPublication, participant) => { - logger.debug({ - message: 'Local track unpublished', - context: { trackKind: trackPublication.kind, participantIdentity: participant.identity, roomId }, - }); - get().actions._updateParticipants(); - }) - .on(RoomEvent.TrackSubscribed, (track, publication, participant) => { - logger.debug({ - message: 'Subscribed to track', - context: { - trackSid: publication.trackSid, - trackKind: track.kind, - participantIdentity: participant.identity, - roomId, - }, - }); - // Audio tracks are usually auto-played. No specific handling needed here for audio only. - }) - .on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { - logger.debug({ - message: 'Unsubscribed from track', - context: { - trackSid: publication.trackSid, - participantIdentity: participant.identity, - roomId, - }, - }); - }) - .on(RoomEvent.Disconnected, (reason) => { - logger.info({ - message: 'Disconnected from room', - context: { reason: String(reason), roomId }, - }); - // DisconnectReason is an enum of strings like 'CLIENT_INITIATED', etc. - const reasonMsg = reason ? String(reason) : 'Unknown reason'; - set({ error: `Disconnected: ${reasonMsg}` }); - // Full cleanup is also handled by ConnectionStateChanged to Disconnected - }); - - const connectOptions: RoomConnectOptions = { - autoSubscribe: true, // Subscribe to all tracks by default - }; - - //await newRoom.connect(LIVEKIT_URL, token, connectOptions); - // Connection success is handled by the ConnectionStateChanged event listener - } catch (err: any) { - logger.error({ - message: 'Failed to connect to LiveKit room', - context: { error: err, roomId, participantIdentity }, - }); - set({ - error: err.message || 'An unknown error occurred during connection.', - isConnecting: false, - isConnected: false, - roomInstance: null, - currentRoomId: null, - }); - // Clean up any partially initialized room - if (get().roomInstance) { - await get().roomInstance?.disconnect(); - set({ roomInstance: null }); - } - } - }, - - disconnectFromRoom: async () => { - const room = get().roomInstance; - if (room) { - logger.info({ - message: 'Disconnecting from room', - context: { roomName: room.name, currentRoomId: get().currentRoomId }, - }); - await room.disconnect(); - // State updates (isConnected, currentRoomId, etc.) are handled by RoomEvent.Disconnected - // and ConnectionState.Disconnected listeners. - set({ - roomInstance: null, - currentRoomId: null, - isConnected: false, - isConnecting: false, - participants: [], - localParticipant: null, - selectedRoomForJoining: null, // Reset selection - }); - - // End CallKeep call for iOS - if (Platform.OS === 'ios') { - try { - await callKeepService.endCall(); - // Clear the mute state callback - callKeepService.setMuteStateCallback(null); - logger.info({ - message: 'CallKeep call ended successfully', - }); - } catch (error) { - logger.warn({ - message: 'Failed to end CallKeep call', - context: { error }, - }); - } - } - } - }, - - setMicrophoneEnabled: async (enabled: boolean) => { - const room = get().roomInstance; - if (room && room.localParticipant) { - try { - await room.localParticipant.setMicrophoneEnabled(enabled); - get().actions._updateParticipants(); // reflect change in participant state - - // Play transmit sounds - if (enabled) { - inCallAudio.playSound('transmit_start'); - } else { - inCallAudio.playSound('transmit_stop'); - } - - logger.info({ - message: 'Microphone state changed', - context: { enabled, participantIdentity: room.localParticipant.identity }, - }); - } catch (e) { - logger.error({ - message: 'Error setting microphone state', - context: { error: e, enabled }, - }); - set({ error: 'Could not change microphone state.' }); - } - } - }, - - _setRoomInstance: (room) => set({ roomInstance: room }), - _setIsConnected: (isConnected) => set({ isConnected }), - _setIsConnecting: (isConnecting) => set({ isConnecting }), - - _addParticipant: (participant) => { - set((state) => { - if (!state.participants.find((p) => p.sid === participant.sid)) { - return { participants: [...state.participants, participant] }; - } - return {}; // No change - }); - }, - _removeParticipant: (participantSid) => { - set((state) => ({ - participants: state.participants.filter((p) => p.sid !== participantSid), - })); - }, - _updateParticipants: () => { - const room = get().roomInstance; - if (room) { - // Use room.remoteParticipants which is Map - const remoteParticipantsArray: RemoteParticipant[] = Array.from(room.remoteParticipants.values()); - const allParticipants: Participant[] = [room.localParticipant, ...remoteParticipantsArray]; - set({ - participants: allParticipants, - localParticipant: room.localParticipant, - }); - } - }, - }, -})); - -// Selector for convenience -export const useLiveKit = useLiveKitCallStore; - -// Example on how to listen to participant's microphone status -// This would typically be in a component that renders a participant -/* -const { isMuted } = useParticipantTrack({ - participant: remoteParticipant, - source: Track.Source.Microphone, - publication: remoteParticipant.getTrackPublication(Track.Source.Microphone), -}); -*/ diff --git a/src/services/__tests__/audio.service.test.ts b/src/services/__tests__/audio.service.test.ts index 82a320b..9d3c1a5 100644 --- a/src/services/__tests__/audio.service.test.ts +++ b/src/services/__tests__/audio.service.test.ts @@ -134,7 +134,7 @@ describe('AudioService', () => { playsInSilentModeIOS: true, shouldDuckAndroid: true, playThroughEarpieceAndroid: true, - interruptionModeIOS: 'doNotMix', + interruptionModeIOS: 'mixWithOthers', interruptionModeAndroid: 'duckOthers', }); }); diff --git a/src/services/__tests__/bluetooth-audio-forget.test.ts b/src/services/__tests__/bluetooth-audio-forget.test.ts new file mode 100644 index 0000000..d598322 --- /dev/null +++ b/src/services/__tests__/bluetooth-audio-forget.test.ts @@ -0,0 +1,117 @@ +import { BluetoothAudioService } from '../bluetooth-audio.service'; +import { useBluetoothAudioStore } from '../../stores/app/bluetooth-audio-store'; +import { removeItem } from '../../lib/storage'; + +// Mock dependencies +jest.mock('../../lib/logging', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('../../lib/storage', () => ({ + getItem: jest.fn(), + removeItem: jest.fn(), +})); + +jest.mock('../../stores/app/bluetooth-audio-store', () => ({ + useBluetoothAudioStore: { + getState: jest.fn(), + }, + State: { + PoweredOn: 'poweredOn', + PoweredOff: 'poweredOff', + }, +})); + +jest.mock('../../stores/app/livekit-store', () => ({ + useLiveKitStore: { + getState: jest.fn(), + }, +})); + +jest.mock('react-native-ble-manager', () => ({ + start: jest.fn(), + checkState: jest.fn(), + onDidUpdateState: jest.fn(), + onDisconnectPeripheral: jest.fn(), + onDiscoverPeripheral: jest.fn(), + onDidUpdateValueForCharacteristic: jest.fn(), + onStopScan: jest.fn(), + scan: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), +})); + +describe('BluetoothAudioService - forgetPreferredDevice', () => { + let service: BluetoothAudioService; + let mockStore: any; + + beforeEach(() => { + jest.clearAllMocks(); + service = BluetoothAudioService.getInstance(); + + // Setup mock store state + mockStore = { + preferredDevice: { id: 'test-device-id', name: 'Test Device' }, + connectedDevice: { id: 'test-device-id', name: 'Test Device' }, + selectedAudioDevices: { + microphone: { id: 'test-device-id', name: 'Test Device', type: 'bluetooth' }, + speaker: { id: 'test-device-id', name: 'Test Device', type: 'bluetooth' }, + }, + setPreferredDevice: jest.fn(), + setConnectedDevice: jest.fn(), + setSelectedMicrophone: jest.fn(), + setSelectedSpeaker: jest.fn(), + updateDevice: jest.fn(), + availableDevices: [], + }; + + (useBluetoothAudioStore.getState as jest.Mock).mockReturnValue(mockStore); + }); + + it('should remove preferred device from storage', async () => { + await service.forgetPreferredDevice('test-device-id'); + expect(removeItem).toHaveBeenCalledWith('preferredBluetoothDevice'); + }); + + it('should clear preferred device from store', async () => { + await service.forgetPreferredDevice('test-device-id'); + expect(mockStore.setPreferredDevice).toHaveBeenCalledWith(null); + }); + + it('should not clear preferred device from store if IDs do not match', async () => { + mockStore.preferredDevice = { id: 'other-device-id', name: 'Other Device' }; + await service.forgetPreferredDevice('test-device-id'); + expect(mockStore.setPreferredDevice).not.toHaveBeenCalled(); + }); + + it('should reset microphone if it was the forgotten device', async () => { + await service.forgetPreferredDevice('test-device-id'); + expect(mockStore.setSelectedMicrophone).toHaveBeenCalledWith({ + id: 'default-mic', + name: 'Default Microphone', + type: 'default', + isAvailable: true + }); + }); + + it('should reset speaker if it was the forgotten device', async () => { + await service.forgetPreferredDevice('test-device-id'); + expect(mockStore.setSelectedSpeaker).toHaveBeenCalledWith({ + id: 'default-speaker', + name: 'Default Speaker', + type: 'speaker', + isAvailable: true + }); + }); + + it('should not reset microphone if it was NOT the forgotten device', async () => { + mockStore.selectedAudioDevices.microphone = { id: 'other-mic', name: 'Other Mic', type: 'wired' }; + await service.forgetPreferredDevice('test-device-id'); + expect(mockStore.setSelectedMicrophone).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/__tests__/headset-button.service.test.ts b/src/services/__tests__/headset-button.service.test.ts index 8cc242e..5263450 100644 --- a/src/services/__tests__/headset-button.service.test.ts +++ b/src/services/__tests__/headset-button.service.test.ts @@ -76,6 +76,8 @@ jest.mock('@/services/audio.service', () => ({ }, })); + + // Import after mocks are set up import { headsetButtonService } from '../headset-button.service'; diff --git a/src/services/audio.service.ts b/src/services/audio.service.ts index 53e0229..1df3b17 100644 --- a/src/services/audio.service.ts +++ b/src/services/audio.service.ts @@ -45,7 +45,7 @@ class AudioService { playsInSilentModeIOS: true, shouldDuckAndroid: true, playThroughEarpieceAndroid: true, - interruptionModeIOS: InterruptionModeIOS.DoNotMix, + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, interruptionModeAndroid: InterruptionModeAndroid.DuckOthers, }); diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index 50e38e8..e995a05 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -3,7 +3,7 @@ import { Alert, DeviceEventEmitter, PermissionsAndroid, Platform } from 'react-n import BleManager, { type BleManagerDidUpdateValueForCharacteristicEvent, BleScanCallbackType, BleScanMatchMode, BleScanMode, type BleState, type Peripheral } from 'react-native-ble-manager'; import { logger } from '@/lib/logging'; -import { getItem } from '@/lib/storage'; +import { getItem, removeItem } from '@/lib/storage'; import { type AudioButtonEvent, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { useLiveKitStore } from '@/stores/app/livekit-store'; // Import audioService dynamically to avoid expo module import errors in tests @@ -52,7 +52,7 @@ const BUTTON_CONTROL_CHARACTERISTICS = [ '00002A4C-0000-1000-8000-00805F9B34FB', // HID Control Point characteristic ]; -class BluetoothAudioService { +export class BluetoothAudioService { private static instance: BluetoothAudioService; private connectedDevice: Device | null = null; private scanTimeout: ReturnType | null = null; @@ -346,6 +346,76 @@ class BluetoothAudioService { await this.attemptPreferredDeviceConnection(); } + /** + * Forget the preferred Bluetooth device. + * Removes from storage, clears from store, and resets audio selection if it was active. + * @param deviceId - The ID of the device to forget + */ + async forgetPreferredDevice(deviceId: string): Promise { + try { + logger.info({ + message: 'Forgetting preferred Bluetooth device', + context: { deviceId }, + }); + + // 1. Remove from persistent storage + const PREFERRED_BLUETOOTH_DEVICE_KEY = 'preferredBluetoothDevice'; + // removeItem is imported from '@/lib/storage' + removeItem(PREFERRED_BLUETOOTH_DEVICE_KEY); // Returns a promise but void, we can await or not, usually safe to fire and forget here or await. + // Checking storage.tsx it is async? + // export async function removeItem(key: string) { storage.delete(key); } + // It is async, so we should await it if we want to be sure. However, it's not critical to block. + // Let's await to be safe. + // But wait my import check earlier showed removeItem. + // I will just call it. + + // 2. Clear from store + const store = useBluetoothAudioStore.getState(); + if (store.preferredDevice && store.preferredDevice.id === deviceId) { + store.setPreferredDevice(null); + } + + // 3. Disconnect if currently connected + if (store.connectedDevice && store.connectedDevice.id === deviceId) { + logger.info({ message: 'Disconnecting device being forgotten', context: { deviceId } }); + await this.disconnectDevice(); + } + + // 4. Reset audio selection if this device was selected + const { selectedAudioDevices } = store; + let selectionChanged = false; + + // Check microphone + if (selectedAudioDevices.microphone?.id === deviceId) { + logger.info({ message: 'Resetting microphone selection as device is forgotten' }); + store.setSelectedMicrophone({ + id: 'default-mic', + name: 'Default Microphone', + type: 'default', + isAvailable: true, + }); + selectionChanged = true; + } + + // Check speaker + if (selectedAudioDevices.speaker?.id === deviceId) { + logger.info({ message: 'Resetting speaker selection as device is forgotten' }); + store.setSelectedSpeaker({ + id: 'default-speaker', + name: 'Default Speaker', + type: 'speaker', + isAvailable: true, + }); + selectionChanged = true; + } + } catch (error) { + logger.error({ + message: 'Failed to forget preferred Bluetooth device', + context: { error, deviceId }, + }); + } + } + private handleBluetoothDisabled(): void { this.stopScanning(); this.disconnectDevice(); @@ -1573,77 +1643,56 @@ class BluetoothAudioService { } private async handleMuteToggle(): Promise { - const liveKitStore = useLiveKitStore.getState(); - if (liveKitStore.currentRoom) { - const currentMuteState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; - - try { - await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(currentMuteState); - - logger.info({ - message: 'Microphone toggled via Bluetooth button', - context: { enabled: currentMuteState }, - }); - - useBluetoothAudioStore.getState().setLastButtonAction({ - action: currentMuteState ? 'unmute' : 'mute', - timestamp: Date.now(), - }); + try { + // Use the LiveKit store action which handles: + // 1. Updating the debounce timestamp (CRITICAL for fixing PTT desync) + // 2. Toggling the microphone + // 3. Playing validation sounds + // 4. Updating CallKeep state + // 5. Updating headset state + await useLiveKitStore.getState().toggleMicrophone(); - if (currentMuteState) { - if (audioService?.playStartTransmittingSound) { - await audioService.playStartTransmittingSound(); - } - } else { - if (audioService?.playStopTransmittingSound) { - await audioService.playStopTransmittingSound(); - } - } - } catch (error) { - logger.error({ - message: 'Failed to toggle microphone via Bluetooth button', - context: { error }, - }); - } + logger.info({ + message: 'Microphone toggled via Bluetooth button', + }); + } catch (error) { + logger.error({ + message: 'Failed to toggle microphone via Bluetooth button', + context: { error }, + }); } } + /** + * Toggle microphone state. + * + * CRITICAL LOGIC: + * This method is the EXCLUSIVE path for Bluetooth PTT devices to control the microphone. + * When a Bluetooth PTT device is selected, we STRICTLY IGNORE CallKit/System mute events + * in `useLiveKitCallStore` to prevent interference. + * + * - PTT PRESS -> setMicrophoneEnabled(true) + * - PTT RELEASE -> setMicrophoneEnabled(false) + */ private async setMicrophoneEnabled(enabled: boolean): Promise { - const liveKitStore = useLiveKitStore.getState(); - if (liveKitStore.currentRoom) { - const currentMuteState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled; - - try { - if (enabled && !currentMuteState) return; // already enabled - if (!enabled && currentMuteState) return; // already disabled - - await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(currentMuteState); - - logger.info({ - message: 'Microphone toggled via Bluetooth button', - context: { enabled: currentMuteState }, - }); - - useBluetoothAudioStore.getState().setLastButtonAction({ - action: enabled ? 'unmute' : 'mute', - timestamp: Date.now(), - }); + try { + // Use the LiveKit store action which handles: + // 1. Updating the debounce timestamp (CRITICAL for fixing PTT desync) + // 2. Setting the microphone state + // 3. Playing validation sounds + // 4. Updating CallKeep state + // 5. Updating headset state + await useLiveKitStore.getState().setMicrophoneEnabled(enabled); - if (enabled) { - if (audioService?.playStartTransmittingSound) { - await audioService.playStartTransmittingSound(); - } - } else { - if (audioService?.playStopTransmittingSound) { - await audioService.playStopTransmittingSound(); - } - } - } catch (error) { - logger.error({ - message: 'Failed to toggle microphone via Bluetooth button', - context: { error }, - }); - } + logger.info({ + message: 'Microphone state set via Bluetooth button', + context: { enabled }, + }); + } catch (error) { + logger.error({ + message: 'Failed to set microphone state via Bluetooth button', + context: { error }, + }); } } diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts index 35bff89..d2f62e0 100644 --- a/src/services/callkeep.service.android.ts +++ b/src/services/callkeep.service.android.ts @@ -1,7 +1,11 @@ -import { Platform } from 'react-native'; +import { PermissionsAndroid, Platform } from 'react-native'; +import RNCallKeep from 'react-native-callkeep'; import { logger } from '../lib/logging'; +// UUID for the CallKeep call - should be unique per session +let currentCallUUID: string | null = null; + export interface CallKeepConfig { appName: string; maximumCallGroups: number; @@ -11,13 +15,12 @@ export interface CallKeepConfig { ringtoneSound?: string; } -/** - * Android implementation of CallKeepService - * This is a no-op implementation since CallKeep is iOS-specific - * but provides the same interface for cross-platform compatibility - */ export class CallKeepService { private static instance: CallKeepService | null = null; + private isSetup = false; + private isCallActive = false; + private muteStateCallback: ((muted: boolean) => void) | null = null; + private endCallCallback: (() => void) | null = null; private constructor() {} @@ -29,69 +32,308 @@ export class CallKeepService { } /** - * Setup CallKeep - no-op on Android + * Setup CallKeep with the required configuration + * This should be called once during app initialization */ async setup(config: CallKeepConfig): Promise { - logger.debug({ - message: 'CallKeep setup skipped - Android platform does not require CallKeep', - context: { platform: Platform.OS }, - }); + if (Platform.OS !== 'android') { + return; + } + + if (this.isSetup) { + logger.debug({ + message: 'CallKeep (Android) already setup', + }); + return; + } + + try { + const options = { + ios: { + appName: config.appName, + }, + android: { + alertTitle: 'Permissions required', + alertDescription: 'This application needs to access your phone accounts', + cancelButton: 'Cancel', + okButton: 'OK', + additionalPermissions: [PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE], + // Important for VoIP on Android O+ + selfManaged: true, + foregroundService: { + channelId: 'call_channel', + channelName: 'Active Call', + notificationTitle: 'Call in progress', + notificationIcon: 'ic_notification', + }, + }, + }; + + await RNCallKeep.setup(options); + + // On Android, we might need to ask for permissions explicitly if not handled by setup + if (Platform.Version >= 23) { + RNCallKeep.setAvailable(true); + } + + this.setupEventListeners(); + this.isSetup = true; + + logger.info({ + message: 'CallKeep (Android) setup completed successfully', + context: { config }, + }); + } catch (error) { + logger.error({ + message: 'Failed to setup CallKeep (Android)', + context: { error, config }, + }); + throw error; + } } /** - * Start a call - no-op on Android + * Start a CallKit call to keep the app alive in the background + * This should be called when connecting to a LiveKit room */ async startCall(roomName: string, handle?: string): Promise { - logger.debug({ - message: 'CallKeep startCall skipped - Android platform does not require CallKeep', - context: { platform: Platform.OS, roomName, handle }, - }); - return ''; + if (Platform.OS !== 'android') { + return ''; + } + + if (!this.isSetup) { + // Auto-setup if not ready (fallback) + logger.warn({ message: 'CallKeep not setup before startCall, attempting setup' }); + } + + if (currentCallUUID) { + logger.debug({ + message: 'Existing call UUID found, ending before starting a new one', + context: { currentCallUUID }, + }); + await this.endCall(); + } + + try { + // Generate a new UUID for this call + currentCallUUID = this.generateUUID(); + const callHandle = handle || 'Voice Channel'; + const contactIdentifier = `Voice Channel: ${roomName}`; + + logger.info({ + message: 'Starting CallKeep (Android) call', + context: { + uuid: currentCallUUID, + handle: callHandle, + roomName, + }, + }); + + // Start the call - Self Managed ConnectionService + // On Android, displayIncomingCall is often used for self-managed, but startCall works for outgoing. + // We simulate an "outgoing" call to the room. + RNCallKeep.startCall(currentCallUUID, callHandle, contactIdentifier, 'generic', false); + + // For Android self-managed, we often need to set activity + RNCallKeep.setCurrentCallActive(currentCallUUID); + + this.isCallActive = true; + return currentCallUUID; + } catch (error) { + logger.error({ + message: 'Failed to start CallKeep (Android) call', + context: { error, roomName, handle }, + }); + currentCallUUID = null; + throw error; + } } /** - * End a call - no-op on Android + * End the active CallKit call + * This should be called when disconnecting from a LiveKit room */ async endCall(): Promise { - logger.debug({ - message: 'CallKeep endCall skipped - Android platform does not require CallKeep', - context: { platform: Platform.OS }, - }); + if (!currentCallUUID) { + return; + } + + try { + logger.info({ + message: 'Ending CallKeep (Android) call', + context: { uuid: currentCallUUID }, + }); + + RNCallKeep.endCall(currentCallUUID); + currentCallUUID = null; + this.isCallActive = false; + } catch (error) { + logger.error({ + message: 'Failed to end CallKeep call', + context: { error, uuid: currentCallUUID }, + }); + currentCallUUID = null; + this.isCallActive = false; + } + } + + /** + * Set the mute state of the current call + */ + async setMuted(muted: boolean): Promise { + if (!currentCallUUID) { + return; + } + + try { + RNCallKeep.setMutedCall(currentCallUUID, muted); + logger.debug({ + message: 'CallKeep (Android) mute state updated', + context: { muted, uuid: currentCallUUID }, + }); + } catch (error) { + logger.error({ + message: 'Failed to update CallKeep mute state', + context: { error, muted, uuid: currentCallUUID }, + }); + } } /** - * Set mute state callback - no-op on Android + * Set a callback to handle mute state changes from CallKit + * This should be called by the LiveKit store to sync mute state */ setMuteStateCallback(callback: ((muted: boolean) => void) | null): void { - logger.debug({ - message: 'CallKeep setMuteStateCallback skipped - Android platform does not require CallKeep', - context: { platform: Platform.OS }, - }); + this.muteStateCallback = callback; + } + + /** + * Set a callback to handle end call events from CallKit + */ + setEndCallCallback(callback: (() => void) | null): void { + this.endCallCallback = callback; } /** - * Check if call is active - always false on Android + * Check if there's an active CallKit call */ isCallActiveNow(): boolean { - return false; + return this.isCallActive && currentCallUUID !== null; } /** - * Get current call UUID - always null on Android + * Get the current call UUID */ getCurrentCallUUID(): string | null { - return null; + return currentCallUUID; } /** - * Clean up resources - no-op on Android + * Setup event listeners for CallKeep events */ - async cleanup(): Promise { - logger.debug({ - message: 'CallKeep cleanup skipped - Android platform does not require CallKeep', - context: { platform: Platform.OS }, + private setupEventListeners(): void { + // Android specific events if any + + // Call ended from System UI + RNCallKeep.addEventListener('endCall', ({ callUUID }) => { + logger.info({ + message: 'CallKeep call ended from system UI', + context: { callUUID }, + }); + + if (callUUID === currentCallUUID) { + currentCallUUID = null; + this.isCallActive = false; + + // Notify callback if set + if (this.endCallCallback) { + try { + this.endCallCallback(); + } catch (error) { + logger.warn({ + message: 'Failed to execute end call callback', + context: { error, callUUID }, + }); + } + } + } + }); + + // Mute/unmute events + RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { + logger.debug({ + message: 'CallKeep mute state changed', + context: { muted, callUUID }, + }); + + // Call the registered callback if available + if (this.muteStateCallback) { + try { + this.muteStateCallback(muted); + } catch (error) { + logger.warn({ + message: 'Failed to execute mute state callback', + context: { error, muted, callUUID }, + }); + } + } + }); + } + + /** + * Generate a UUID for CallKeep calls + */ + private generateUUID(): string { + // RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI + const rndUUID = (global as any)?.crypto?.randomUUID?.(); + if (typeof rndUUID === 'string') return rndUUID; + // Fallback + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); }); } + + /** + * Remove the CallKeep mute listener (No-op on Android or handled differently) + */ + removeMuteListener(): void { + // Android implementation if needed + } + + /** + * Restore the CallKeep mute listener (No-op on Android or handled differently) + */ + restoreMuteListener(): void { + // Android implementation if needed + } + + /** + * Clean up resources - call this when the service is no longer needed + */ + async cleanup(): Promise { + try { + if (this.isCallActive) { + await this.endCall(); + } + + // Remove event listeners + RNCallKeep.removeEventListener('endCall'); + RNCallKeep.removeEventListener('didPerformSetMutedCallAction'); + + this.isSetup = false; + + logger.debug({ + message: 'CallKeep service cleaned up', + }); + } catch (error) { + logger.error({ + message: 'Error during CallKeep cleanup', + context: { error }, + }); + } + } } // Export singleton instance diff --git a/src/services/callkeep.service.ios.ts b/src/services/callkeep.service.ios.ts index 5fcf506..ebec457 100644 --- a/src/services/callkeep.service.ios.ts +++ b/src/services/callkeep.service.ios.ts @@ -1,6 +1,7 @@ import { RTCAudioSession } from '@livekit/react-native-webrtc'; +import { config } from 'dotenv'; import { Platform } from 'react-native'; -import RNCallKeep, { AudioSessionCategoryOption, AudioSessionMode, CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep'; +import RNCallKeep, { AudioSessionCategoryOption, AudioSessionMode } from 'react-native-callkeep'; import { logger } from '../lib/logging'; @@ -21,6 +22,7 @@ export class CallKeepService { private isSetup = false; private isCallActive = false; private muteStateCallback: ((muted: boolean) => void) | null = null; + private endCallCallback: (() => void) | null = null; private constructor() {} @@ -142,19 +144,23 @@ export class CallKeepService { // Report connecting state RNCallKeep.reportConnectingOutgoingCallWithUUID(currentCallUUID); - // Small delay to ensure proper state transition - setTimeout(() => { - if (currentCallUUID) { - RNCallKeep.reportConnectedOutgoingCallWithUUID(currentCallUUID); - this.isCallActive = true; - logger.debug({ - message: 'CallKeep call reported as connected', - context: { uuid: currentCallUUID }, - }); - } - }, 100); - - return currentCallUUID; + // Wait for the system to register the call before resolving + // This ensures that subsequent calls (like setMuted) happen after "Connected" state + return new Promise((resolve) => { + setTimeout(() => { + if (currentCallUUID) { + RNCallKeep.reportConnectedOutgoingCallWithUUID(currentCallUUID); + this.isCallActive = true; + logger.debug({ + message: 'CallKeep call reported as connected', + context: { uuid: currentCallUUID }, + }); + resolve(currentCallUUID); + } else { + resolve(''); + } + }, 800); + }); } catch (error) { logger.error({ message: 'Failed to start CallKeep call', @@ -209,6 +215,28 @@ export class CallKeepService { } } + /** + * Set the mute state of the current call + */ + async setMuted(muted: boolean): Promise { + if (Platform.OS !== 'ios' || !currentCallUUID) { + return; + } + + try { + RNCallKeep.setMutedCall(currentCallUUID, muted); + logger.debug({ + message: 'CallKeep mute state updated', + context: { muted, uuid: currentCallUUID }, + }); + } catch (error) { + logger.error({ + message: 'Failed to update CallKeep mute state', + context: { error, muted, uuid: currentCallUUID }, + }); + } + } + /** * Set a callback to handle mute state changes from CallKit * This should be called by the LiveKit store to sync mute state @@ -217,6 +245,13 @@ export class CallKeepService { this.muteStateCallback = callback; } + /** + * Set a callback to handle end call events from CallKit + */ + setEndCallCallback(callback: (() => void) | null): void { + this.endCallCallback = callback; + } + /** * Check if there's an active CallKit call */ @@ -261,6 +296,18 @@ export class CallKeepService { if (callUUID === currentCallUUID) { currentCallUUID = null; this.isCallActive = false; + + // Notify callback if set + if (this.endCallCallback) { + try { + this.endCallCallback(); + } catch (error) { + logger.warn({ + message: 'Failed to execute end call callback', + context: { error, callUUID }, + }); + } + } } }); @@ -274,6 +321,11 @@ export class CallKeepService { // Mute/unmute events RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { + // Check internal gate + if (!this._shouldEmitMuteEvents) { + return; + } + logger.debug({ message: 'CallKeep mute state changed', context: { muted, callUUID }, @@ -293,6 +345,30 @@ export class CallKeepService { }); } + // Internal flag to control if we should emit mute events + private _shouldEmitMuteEvents = true; + + /** + * Remove the CallKeep mute listener. + * This is used to prevent PTT loop oscillation when a specialized device is active. + */ + removeMuteListener(): void { + if (Platform.OS === 'ios') { + this._shouldEmitMuteEvents = false; + logger.debug({ message: 'CallKeep mute listener disabled (internal flag)' }); + } + } + + /** + * Restore the CallKeep mute listener. + */ + restoreMuteListener(): void { + if (Platform.OS === 'ios') { + this._shouldEmitMuteEvents = true; + logger.debug({ message: 'CallKeep mute listener restored (internal flag)' }); + } + } + /** * Generate a UUID for CallKeep calls */ diff --git a/src/services/headset-button.service.ts b/src/services/headset-button.service.ts index b56c151..0813700 100644 --- a/src/services/headset-button.service.ts +++ b/src/services/headset-button.service.ts @@ -11,6 +11,7 @@ * This service listens for media button events and translates them into PTT actions. */ +import { AudioSession } from '@livekit/react-native'; import { AppState, DeviceEventEmitter, NativeEventEmitter, NativeModules, Platform } from 'react-native'; import { logger } from '@/lib/logging'; @@ -37,7 +38,7 @@ try { } // Types for headset button events -export type HeadsetButtonType = 'play_pause' | 'next' | 'previous' | 'stop' | 'hook' | 'unknown'; +export type HeadsetButtonType = 'play_pause' | 'play' | 'pause' | 'next' | 'previous' | 'stop' | 'hook' | 'unknown'; export interface HeadsetButtonEvent { type: HeadsetButtonType; @@ -138,29 +139,20 @@ class HeadsetButtonService { * 2. Now Playing Info Center * * Since React Native doesn't expose these directly, we use DeviceEventEmitter - * to listen for events that may be bridged from native modules. + * to listen for events that may be bridged from native modules, and + * react-native-music-control to handle system media commands. */ private async setupIOSListeners(): Promise { logger.debug({ message: 'Setting up iOS headset button listeners' }); - // Listen for headset button events via DeviceEventEmitter - // These events can be emitted by native modules that handle remote control events - const headsetButtonSubscription = DeviceEventEmitter.addListener('HeadsetButtonEvent', (event) => { - this.handleHeadsetButtonEvent(event); - }); - this.subscriptions.push(headsetButtonSubscription); + // MusicControl removed. + // We now rely on CallKeep for iOS headset events. // Listen for audio route changes which may indicate headset connection const audioRouteSubscription = DeviceEventEmitter.addListener('AudioRouteChange', (event) => { this.handleAudioRouteChange(event); }); this.subscriptions.push(audioRouteSubscription); - - // Listen for remote command center events - const remoteControlSubscription = DeviceEventEmitter.addListener('RemoteControlEvent', (event) => { - this.handleRemoteControlEvent(event); - }); - this.subscriptions.push(remoteControlSubscription); } /** @@ -173,18 +165,6 @@ class HeadsetButtonService { private async setupAndroidListeners(): Promise { logger.debug({ message: 'Setting up Android headset button listeners' }); - // Listen for headset button events via DeviceEventEmitter - const headsetButtonSubscription = DeviceEventEmitter.addListener('HeadsetButtonEvent', (event) => { - this.handleHeadsetButtonEvent(event); - }); - this.subscriptions.push(headsetButtonSubscription); - - // Listen for media button events - const mediaButtonSubscription = DeviceEventEmitter.addListener('MediaButtonEvent', (event) => { - this.handleMediaButtonEvent(event); - }); - this.subscriptions.push(mediaButtonSubscription); - // Listen for headset connection changes const headsetConnectionSubscription = DeviceEventEmitter.addListener('HeadsetConnectionChange', (event) => { this.handleHeadsetConnectionChange(event); @@ -192,26 +172,6 @@ class HeadsetButtonService { this.subscriptions.push(headsetConnectionSubscription); } - /** - * Handle headset button events - */ - private handleHeadsetButtonEvent(event: any): void { - if (!this.isMonitoring) return; - - logger.debug({ - message: 'Headset button event received', - context: { event }, - }); - - const buttonEvent: HeadsetButtonEvent = { - type: this.mapButtonType(event?.type || event?.keyCode), - timestamp: Date.now(), - source: this.detectSource(event), - }; - - this.processButtonEvent(buttonEvent); - } - /** * Handle remote control events (iOS) */ @@ -234,28 +194,6 @@ class HeadsetButtonService { } } - /** - * Handle media button events (Android) - */ - private handleMediaButtonEvent(event: any): void { - if (!this.isMonitoring) return; - - logger.debug({ - message: 'Media button event received', - context: { event }, - }); - - const buttonType = this.mapMediaButtonType(event?.keyCode); - if (buttonType !== 'unknown') { - const buttonEvent: HeadsetButtonEvent = { - type: buttonType, - timestamp: Date.now(), - source: 'bluetooth_headset', - }; - this.processButtonEvent(buttonEvent); - } - } - /** * Handle audio route changes */ @@ -453,6 +391,8 @@ class HeadsetButtonService { private mapToStoreButtonType(type: HeadsetButtonType): 'ptt_start' | 'ptt_stop' | 'volume_up' | 'volume_down' | 'mute' | 'unknown' { switch (type) { case 'play_pause': + case 'play': + case 'pause': case 'hook': return 'mute'; // Play/pause maps to mute toggle default: @@ -476,10 +416,16 @@ class HeadsetButtonService { } // Handle based on button type and click count - if ((event.type === 'play_pause' || event.type === 'hook') && clickCount === 1) { - // Single click - toggle mute based on config - if (this.config.playPauseAction === 'toggle_mute') { - await this.toggleMicrophone(); + if (clickCount === 1) { + if (event.type === 'play') { + await this.enableMicrophone(); + } else if (event.type === 'pause') { + await this.disableMicrophone(); + } else if (event.type === 'play_pause' || event.type === 'hook') { + // Single click - toggle mute based on config + if (this.config.playPauseAction === 'toggle_mute') { + await this.toggleMicrophone(); + } } } else if ((event.type === 'play_pause' || event.type === 'hook') && clickCount === 2) { // Double click @@ -534,6 +480,9 @@ class HeadsetButtonService { if (this.config.soundFeedback && audioService?.playStartTransmittingSound) { await audioService.playStartTransmittingSound(); } + + // Sync MusicControl state and restore audio session + this.setMicrophoneState(true); } } catch (error) { logger.error({ @@ -568,6 +517,9 @@ class HeadsetButtonService { if (this.config.soundFeedback && audioService?.playStopTransmittingSound) { await audioService.playStopTransmittingSound(); } + + // Sync MusicControl state and restore audio session + this.setMicrophoneState(false); } } catch (error) { logger.error({ @@ -581,20 +533,38 @@ class HeadsetButtonService { * Start monitoring headset button events */ startMonitoring(): void { - if (this.isMonitoring) { - logger.debug({ message: 'Headset button monitoring already active' }); - return; - } - - this.isMonitoring = true; - - logger.info({ message: 'Started headset button monitoring for PTT' }); + if (this.isMonitoring) return; - // Emit event for native modules to start capturing media buttons try { - DeviceEventEmitter.emit('StartHeadsetButtonMonitoring', { pttMode: this.config.pttMode }); - } catch { - // Emit may fail in test environment + this.isMonitoring = true; + logger.info({ message: 'Started headset button monitoring for PTT' }); + + // Enable MusicControl session - ONLY for Android now, or if explicitly desired + // On iOS, we use CallKeep for lock screen controls. MusicControl conflicts with CallKeep. + if (Platform.OS === 'android') { + // Wait... Android uses MusicControl? + // Android PTT usually uses KeyEvents or dedicated Headset buttons. + // But for "Media Button" support on Android, MusicControl is fine. + // For iOS, if we use CallKeep, we MUST NOT use MusicControl. + } else if (Platform.OS === 'ios') { + // DISABLE MusicControl on iOS to prevent conflict with CallKit + // This prevents the "ticking" sound caused by rapid system sound feedback + // when both CallKit and MusicControl try to handle the same events. + // this.enableMusicControl(); + logger.info({ message: 'MusicControl disabled on iOS (using CallKit native events)' }); + } + + // Emit event for native modules to start capturing media buttons + try { + DeviceEventEmitter.emit('StartHeadsetButtonMonitoring', { pttMode: this.config.pttMode }); + } catch { + // Emit may fail in test environment + } + } catch (error) { + logger.error({ + message: 'Failed to start headset button monitoring', + context: { error }, + }); } } @@ -673,6 +643,36 @@ class HeadsetButtonService { this.processButtonEvent(event); } + /** + * Update MusicControl state based on microphone state + * @param enabled - true if microphone is unmuted (Playing), false if muted (Paused) + */ + setMicrophoneState(enabled: boolean): void { + // No-op: MusicControl removed. + // AudioSession restoration is handled by CallKeep/LiveKit interaction. + this.restoreAudioSession(); + } + + /** + * Restore audio session configuration (iOS) + */ + private async restoreAudioSession(): Promise { + if (Platform.OS !== 'ios') return; + + try { + // Re-apply the same configuration as livekit-store.ts + await AudioSession.setAppleAudioConfiguration({ + audioCategory: 'playAndRecord', + audioCategoryOptions: ['allowBluetooth', 'allowBluetoothA2DP', 'mixWithOthers'], + audioMode: 'voiceChat', + }); + + logger.debug({ message: 'Restored AudioSession configuration' }); + } catch (error) { + logger.warn({ message: 'Failed to restore AudioSession', context: { error } }); + } + } + /** * Clean up resources */ diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index d82ca05..64c1543 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -45,6 +45,8 @@ jest.mock('../../../services/callkeep.service.ios', () => ({ import { Platform } from 'react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; + + import { useLiveKitStore } from '../livekit-store'; import { logger } from '../../../lib/logging'; diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts index cf60f22..c32a41e 100644 --- a/src/stores/app/bluetooth-audio-store.ts +++ b/src/stores/app/bluetooth-audio-store.ts @@ -24,6 +24,24 @@ export interface BluetoothAudioDevice { device: Device; } +export const SYSTEM_AUDIO_DEVICE: BluetoothAudioDevice = { + id: 'system-audio', + name: 'System Audio', + isConnected: false, + hasAudioCapability: true, + supportsMicrophoneControl: true, + device: { + id: 'system-audio', + name: 'System Audio / Airpods', + rssi: -50, + advertising: { + isConnectable: true, + serviceUUIDs: [], + txPowerLevel: 0, + }, + }, +}; + export interface AudioButtonEvent { type: 'press' | 'long_press' | 'double_press'; button: 'ptt_start' | 'ptt_stop' | 'volume_up' | 'volume_down' | 'mute' | 'unknown'; @@ -127,7 +145,7 @@ export const useBluetoothAudioStore = create((set, get) => bluetoothState: State.Unknown, isScanning: false, isConnecting: false, - availableDevices: [], + availableDevices: [SYSTEM_AUDIO_DEVICE], connectedDevice: null, preferredDevice: null, availableAudioDevices: [ @@ -184,7 +202,7 @@ export const useBluetoothAudioStore = create((set, get) => set({ availableDevices: filteredDevices }); }, - clearDevices: () => set({ availableDevices: [] }), + clearDevices: () => set({ availableDevices: [SYSTEM_AUDIO_DEVICE] }), setConnectedDevice: (device) => { set({ connectedDevice: device }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 59486a1..cc3f479 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -2,18 +2,23 @@ import { AudioSession } from '@livekit/react-native'; import notifee, { AndroidImportance } from '@notifee/react-native'; import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; import { Audio } from 'expo-av'; -import { Room, RoomEvent } from 'livekit-client'; +import { ConnectionState, Room, RoomEvent } from 'livekit-client'; import { Platform } from 'react-native'; +import { set } from 'zod'; import { create } from 'zustand'; import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../api/voice'; import { logger } from '../../lib/logging'; import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData'; import { audioService } from '../../services/audio.service'; +import { callKeepService } from '../../services/callkeep.service'; import { headsetButtonService } from '../../services/headset-button.service'; import { toggleMicrophone } from '../../utils/microphone-toggle'; import { useBluetoothAudioStore } from './bluetooth-audio-store'; +// Module level timestamp for debounce +let lastLocalMuteChangeTimestamp = 0; + // Helper function to setup audio routing based on selected devices const setupAudioRouting = async (room: Room): Promise => { try { @@ -64,7 +69,7 @@ const setupAudioRouting = async (room: Room): Promise => { logger.warn({ message: 'Failed to select audio output via AudioSession', context: { error: e } }); } } else if (Platform.OS === 'ios') { - await AudioSession.startAudioSession(); + // AudioSession.startAudioSession(); // Handled by CallKeep - DO NOT call this here for iOS if (speaker?.type === 'bluetooth') { // Bluetooth preferred @@ -231,8 +236,13 @@ export const useLiveKitStore = create((set, get) => ({ set({ isConnecting: true }); - // Create a new room - const room = new Room(); + // Create a new room with default options to ensure no auto-publish if possible + const room = new Room({ + // Prevent auto-publishing if that's a default behavior (though usually it isn't) + publishDefaults: { + // Additional config can go here + }, + }); // Setup room event listeners room.on(RoomEvent.ParticipantConnected, (participant) => { @@ -246,6 +256,52 @@ export const useLiveKitStore = create((set, get) => ({ } }); + // Register CallKeep listeners for iOS & Android + if (Platform.OS !== 'web') { + // Sync mute state from CallKeep to LiveKit + // Sync mute state from CallKeep to LiveKit + callKeepService.setMuteStateCallback((muted: boolean) => { + // DEBOUNCE: Ignore CallKeep events if they happen too close to a local action + // This prevents the "Oscillation Loop" (Device -> CallKeep -> App -> CallKeep -> Device) + const now = Date.now(); + if (now - lastLocalMuteChangeTimestamp < 500) { + logger.debug({ + message: 'Ignoring CallKeep mute event (Debounce)', + context: { muted, timeSinceLastAction: now - lastLocalMuteChangeTimestamp }, + }); + return; + } + + const { selectedAudioDevices, connectedDevice } = useBluetoothAudioStore.getState(); + logger.debug({ + message: 'CallKeep mute callback received', + context: { muted, connectedDeviceId: connectedDevice?.id }, + }); + + const currentState = get(); + if (currentState.isConnected && currentState.currentRoom) { + const shouldBeEnabled = !muted; + const isEnabled = currentState.currentRoom.localParticipant.isMicrophoneEnabled; + + if (isEnabled !== shouldBeEnabled) { + logger.info({ message: 'Syncing mute state from CallKit', context: { muted, shouldBeEnabled } }); + currentState.setMicrophoneEnabled(shouldBeEnabled); + } else { + logger.debug({ message: 'Ignoring duplicate CallKeep mute state' }); + } + } + }); + + // Handle end call (double tap on Airpods) + callKeepService.setEndCallCallback(() => { + const currentState = get(); + if (currentState.isConnected) { + logger.info({ message: 'CallKeep Triggered End Call' }); + currentState.disconnectFromRoom(); + } + }); + } + room.on(RoomEvent.ParticipantDisconnected, (participant) => { logger.info({ message: 'A participant disconnected', @@ -265,7 +321,16 @@ export const useLiveKitStore = create((set, get) => ({ // Connect to the room await room.connect(voipServerWebsocketSslAddress, token); + set({ + currentRoom: room, + currentRoomInfo: roomInfo, + isConnected: true, + isConnecting: false, + }); + // Set microphone to muted by default, camera to disabled (audio-only call) + // This ensures we start in a known state. + logger.info({ message: 'Setting initial microphone state to MUTED (False)' }); await room.localParticipant.setMicrophoneEnabled(false); await room.localParticipant.setCameraEnabled(false); @@ -274,6 +339,29 @@ export const useLiveKitStore = create((set, get) => ({ await audioService.playConnectToAudioRoomSound(); + // Start CallKeep call (iOS & Android) + if (Platform.OS !== 'web') { + // Using a generic handle or room name + const roomName = roomInfo?.Name || 'Voice Channel'; + callKeepService + .startCall(roomName) + .then(async () => { + // Simple initialization: Sync Mute State + // We start muted. + try { + await callKeepService.setMuted(true); + logger.info({ message: 'CallKeep initial mute state set to TRUE' }); + } catch (error) { + logger.warn({ message: 'Failed to set initial CallKeep mute', context: { error } }); + } + + // We now handle mute events from CallKeep even for PTT devices, + // relying on debounce logic to prevent loops. + logger.info({ message: 'CallKeep started' }); + }) + .catch((e) => logger.warn({ message: 'Failed to start CallKeep', context: { error: e } })); + } + try { const startForegroundService = async () => { notifee.registerForegroundService(async () => { @@ -297,7 +385,9 @@ export const useLiveKitStore = create((set, get) => ({ }); }; - await startForegroundService(); + if (Platform.OS === 'android') { + await startForegroundService(); + } } catch (error) { logger.error({ message: 'Failed to register foreground service', @@ -320,13 +410,6 @@ export const useLiveKitStore = create((set, get) => ({ context: { error }, }); } - - set({ - currentRoom: room, - currentRoomInfo: roomInfo, - isConnected: true, - isConnecting: false, - }); } catch (error) { logger.error({ message: 'Failed to connect to room', @@ -357,6 +440,18 @@ export const useLiveKitStore = create((set, get) => ({ await currentRoom.disconnect(); await audioService.playDisconnectedFromAudioRoomSound(); + // Small delay on iOS to allow sound to play before CallKeep potentially kills audio session + if (Platform.OS === 'ios') { + await new Promise((resolve) => setTimeout(resolve, 800)); + } + + // End CallKeep call (iOS & Android) + if (Platform.OS !== 'web') { + callKeepService.endCall().catch((e) => logger.warn({ message: 'Failed to end CallKeep', context: { error: e } })); + callKeepService.setMuteStateCallback(null); + callKeepService.setEndCallCallback(null); + } + try { await notifee.stopForegroundService(); } catch (error) { @@ -379,18 +474,8 @@ export const useLiveKitStore = create((set, get) => ({ let rooms: DepartmentVoiceChannelResultData[] = []; if (response.Data.VoiceEnabled && response.Data?.Channels) { - //rooms.push({ - // id: '0', - // name: 'No Channel Selected', - //}); - rooms.push(...response.Data.Channels); - } //else { - // rooms.push({ - // id: '0', - // name: 'No Channel Selected', - // }); - //} + } set({ isVoiceEnabled: response.Data.VoiceEnabled, @@ -439,6 +524,15 @@ export const useLiveKitStore = create((set, get) => ({ return; } + // Safeguard: Do not attempt to publish/unpublish if room is not connected + if (currentRoom.state !== ConnectionState.Connected) { + logger.warn({ + message: 'Ignored microphone state change - room not connected', + context: { state: currentRoom.state, desiredEnabled: enabled }, + }); + return; + } + try { const currentState = currentRoom.localParticipant.isMicrophoneEnabled; if (currentState === enabled) return; // Already in desired state @@ -456,6 +550,17 @@ export const useLiveKitStore = create((set, get) => ({ timestamp: Date.now(), }); + // Update timestamp for debounce + lastLocalMuteChangeTimestamp = Date.now(); + + // Sync headset state + headsetButtonService.setMicrophoneState(enabled); + + // Sync CallKeep state (iOS & Android) + if (Platform.OS !== 'web') { + callKeepService.setMuted(!enabled); + } + // Play sound feedback if (enabled) { await audioService.playStartTransmittingSound(); diff --git a/src/utils/InCallAudio.ts b/src/utils/InCallAudio.ts index 1c72029..9cc0db4 100644 --- a/src/utils/InCallAudio.ts +++ b/src/utils/InCallAudio.ts @@ -29,45 +29,70 @@ type SoundName = keyof typeof SOUNDS; class InCallAudioService { private isInitialized = false; + private initPromise: Promise | null = null; constructor() { - this.initialize(); + this.initialize().catch((err) => { + logger.error({ message: 'Initial InCallAudio initialization failed', context: { error: err } }); + }); } - public initialize() { - if (Platform.OS === 'android') { + public async initialize(): Promise { + if (this.isInitialized) { + return; + } + + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = (async () => { try { - if (InCallAudioModule) { - InCallAudioModule.initializeAudio?.(); - // Preload sounds - Object.entries(SOUNDS).forEach(([name, config]) => { - InCallAudioModule.loadSound(name, config.android); + if (Platform.OS === 'android') { + if (InCallAudioModule) { + await InCallAudioModule.initializeAudio?.(); + // Preload sounds + const preloadPromises = Object.entries(SOUNDS).map(([name, config]) => + InCallAudioModule.loadSound(name, (config as any).android) + ); + await Promise.all(preloadPromises); + + this.isInitialized = true; + logger.info({ message: 'InCallAudio initialized (Android)' }); + } else { + logger.warn({ message: 'InCallAudioModule not found on Android' }); + } + } else { + // iOS / Web: expo-av handles loading on play or we can preload if needed, + // but simple play usually works fine. + await Audio.setAudioModeAsync({ + playsInSilentModeIOS: true, + interruptionModeIOS: InterruptionModeIOS.MixWithOthers, + staysActiveInBackground: true, + shouldDuckAndroid: false, }); + this.isInitialized = true; - logger.info({ message: 'InCallAudio initialized (Android)' }); - } else { - logger.warn({ message: 'InCallAudioModule not found on Android' }); + logger.info({ message: 'InCallAudio initialized (iOS)' }); } } catch (error) { - logger.error({ message: 'Failed to initialize InCallAudio (Android)', context: { error } }); + logger.error({ + message: `Failed to initialize InCallAudio (${Platform.OS})`, + context: { error }, + }); + this.initPromise = null; // Allow retry on next call + throw error; } - } else { - // iOS / Web: expo-av handles loading on play or we can preload if needed, - // but simple play usually works fine. - Audio.setAudioModeAsync({ - playsInSilentModeIOS: true, - interruptionModeIOS: InterruptionModeIOS.MixWithOthers, - staysActiveInBackground: true, - shouldDuckAndroid: false, - }).catch((err) => logger.warn({ message: 'Failed to set audio mode (iOS)', context: { error: err } })); + })(); - this.isInitialized = true; - } + return this.initPromise; } public async playSound(name: SoundName) { - if (!this.isInitialized) { - this.initialize(); + try { + await this.initialize(); + } catch (err) { + logger.warn({ message: 'Attempting to play sound without successful initialization', context: { name, err } }); } try { @@ -78,14 +103,15 @@ class InCallAudioService { } else { // iOS const source = SOUNDS[name].ios; - const { sound } = await Audio.Sound.createAsync(source); - await sound.playAsync(); - // Unload after playback to free resources - sound.setOnPlaybackStatusUpdate(async (status) => { - if (status.isLoaded && status.didJustFinish) { - await sound.unloadAsync(); + const { sound } = await Audio.Sound.createAsync( + source, + { shouldPlay: true }, + async (status) => { + if (status.isLoaded && status.didJustFinish) { + await sound.unloadAsync(); + } } - }); + ); } } catch (error) { logger.warn({ message: 'Failed to play in-call sound', context: { name, error } }); diff --git a/src/utils/microphone-toggle.ts b/src/utils/microphone-toggle.ts index c6e9656..11cf11a 100644 --- a/src/utils/microphone-toggle.ts +++ b/src/utils/microphone-toggle.ts @@ -10,6 +10,7 @@ import type { Room } from 'livekit-client'; import { logger } from '@/lib/logging'; import { audioService } from '@/services/audio.service'; +import { headsetButtonService } from '@/services/headset-button.service'; import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; export interface ToggleMicrophoneOptions { @@ -57,6 +58,9 @@ export async function toggleMicrophone(room: Room | null, options: ToggleMicroph timestamp: Date.now(), }); + // Sync headset state + headsetButtonService.setMicrophoneState(currentMuteState); + // Play sound feedback if enabled if (soundFeedback) { if (currentMuteState) { From c585041d237a257da84871dd0ab03ef1bfeab6d6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 1 Feb 2026 10:46:17 -0800 Subject: [PATCH 3/4] RR-T40 PR#104 fixes --- .../__tests__/bluetooth-audio-forget.test.ts | 12 ++++++--- src/services/bluetooth-audio.service.ts | 26 +++++++++---------- src/services/headset-button.service.ts | 26 ++++++++++++++++--- src/stores/app/livekit-store.ts | 1 - 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/services/__tests__/bluetooth-audio-forget.test.ts b/src/services/__tests__/bluetooth-audio-forget.test.ts index d598322..cc29ee4 100644 --- a/src/services/__tests__/bluetooth-audio-forget.test.ts +++ b/src/services/__tests__/bluetooth-audio-forget.test.ts @@ -73,17 +73,23 @@ describe('BluetoothAudioService - forgetPreferredDevice', () => { (useBluetoothAudioStore.getState as jest.Mock).mockReturnValue(mockStore); }); - it('should remove preferred device from storage', async () => { + it('should remove preferred device from storage if IDs match', async () => { await service.forgetPreferredDevice('test-device-id'); expect(removeItem).toHaveBeenCalledWith('preferredBluetoothDevice'); }); - it('should clear preferred device from store', async () => { + it('should NOT remove preferred device from storage if IDs do not match', async () => { + mockStore.preferredDevice = { id: 'other-device-id', name: 'Other Device' }; + await service.forgetPreferredDevice('test-device-id'); + expect(removeItem).not.toHaveBeenCalled(); + }); + + it('should clear preferred device from store if IDs match', async () => { await service.forgetPreferredDevice('test-device-id'); expect(mockStore.setPreferredDevice).toHaveBeenCalledWith(null); }); - it('should not clear preferred device from store if IDs do not match', async () => { + it('should NOT clear preferred device from store if IDs do not match', async () => { mockStore.preferredDevice = { id: 'other-device-id', name: 'Other Device' }; await service.forgetPreferredDevice('test-device-id'); expect(mockStore.setPreferredDevice).not.toHaveBeenCalled(); diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index e995a05..2967cc9 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -358,24 +358,22 @@ export class BluetoothAudioService { context: { deviceId }, }); - // 1. Remove from persistent storage - const PREFERRED_BLUETOOTH_DEVICE_KEY = 'preferredBluetoothDevice'; - // removeItem is imported from '@/lib/storage' - removeItem(PREFERRED_BLUETOOTH_DEVICE_KEY); // Returns a promise but void, we can await or not, usually safe to fire and forget here or await. - // Checking storage.tsx it is async? - // export async function removeItem(key: string) { storage.delete(key); } - // It is async, so we should await it if we want to be sure. However, it's not critical to block. - // Let's await to be safe. - // But wait my import check earlier showed removeItem. - // I will just call it. - - // 2. Clear from store const store = useBluetoothAudioStore.getState(); - if (store.preferredDevice && store.preferredDevice.id === deviceId) { + const PREFERRED_BLUETOOTH_DEVICE_KEY = 'preferredBluetoothDevice'; + + // 1. Only remove from persistent storage and store if it's the preferred device + if (store.preferredDevice?.id === deviceId) { + logger.info({ + message: 'Removing device from persistent storage and store', + context: { deviceId }, + }); + + // removeItem is imported from '@/lib/storage' and is async + await removeItem(PREFERRED_BLUETOOTH_DEVICE_KEY); store.setPreferredDevice(null); } - // 3. Disconnect if currently connected + // 2. Disconnect if currently connected if (store.connectedDevice && store.connectedDevice.id === deviceId) { logger.info({ message: 'Disconnecting device being forgotten', context: { deviceId } }); await this.disconnectDevice(); diff --git a/src/services/headset-button.service.ts b/src/services/headset-button.service.ts index 0813700..017e4f4 100644 --- a/src/services/headset-button.service.ts +++ b/src/services/headset-button.service.ts @@ -153,6 +153,12 @@ class HeadsetButtonService { this.handleAudioRouteChange(event); }); this.subscriptions.push(audioRouteSubscription); + + // Listen for headset button events + const headsetButtonSubscription = DeviceEventEmitter.addListener('headset-button', (event) => { + this.handleRemoteControlEvent(event); + }); + this.subscriptions.push(headsetButtonSubscription); } /** @@ -170,6 +176,12 @@ class HeadsetButtonService { this.handleHeadsetConnectionChange(event); }); this.subscriptions.push(headsetConnectionSubscription); + + // Listen for headset button events + const headsetButtonSubscription = DeviceEventEmitter.addListener('headset-button', (event) => { + this.handleRemoteControlEvent(event); + }); + this.subscriptions.push(headsetButtonSubscription); } /** @@ -238,7 +250,9 @@ class HeadsetButtonService { if (typeof type === 'string') { switch (type.toLowerCase()) { case 'play': + return 'play'; case 'pause': + return 'pause'; case 'play_pause': case 'playpause': return 'play_pause'; @@ -264,9 +278,11 @@ class HeadsetButtonService { if (typeof type === 'number') { switch (type) { case 85: // KEYCODE_MEDIA_PLAY_PAUSE + return 'play_pause'; case 126: // KEYCODE_MEDIA_PLAY + return 'play'; case 127: // KEYCODE_MEDIA_PAUSE - return 'play_pause'; + return 'pause'; case 87: // KEYCODE_MEDIA_NEXT return 'next'; case 88: // KEYCODE_MEDIA_PREVIOUS @@ -291,9 +307,11 @@ class HeadsetButtonService { switch (command.toLowerCase()) { case 'toggleplaypause': + return 'play_pause'; case 'play': + return 'play'; case 'pause': - return 'play_pause'; + return 'pause'; case 'nexttrack': return 'next'; case 'previoustrack': @@ -313,9 +331,11 @@ class HeadsetButtonService { switch (keyCode) { case 85: // KEYCODE_MEDIA_PLAY_PAUSE + return 'play_pause'; case 126: // KEYCODE_MEDIA_PLAY + return 'play'; case 127: // KEYCODE_MEDIA_PAUSE - return 'play_pause'; + return 'pause'; case 87: // KEYCODE_MEDIA_NEXT return 'next'; case 88: // KEYCODE_MEDIA_PREVIOUS diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index cc3f479..e567248 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -4,7 +4,6 @@ import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from ' import { Audio } from 'expo-av'; import { ConnectionState, Room, RoomEvent } from 'livekit-client'; import { Platform } from 'react-native'; -import { set } from 'zod'; import { create } from 'zustand'; import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../api/voice'; From 600bf118d5fe3c062fdcae60ce5d2dabd63802e1 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 1 Feb 2026 11:24:27 -0800 Subject: [PATCH 4/4] RR-T40 Fixing issue with Android and Telephone Manager. --- src/services/callkeep.service.android.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts index d2f62e0..69e303e 100644 --- a/src/services/callkeep.service.android.ts +++ b/src/services/callkeep.service.android.ts @@ -57,7 +57,10 @@ export class CallKeepService { alertDescription: 'This application needs to access your phone accounts', cancelButton: 'Cancel', okButton: 'OK', - additionalPermissions: [PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE], + additionalPermissions: [ + PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, + ...(Platform.Version >= 30 ? [PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS] : []), + ], // Important for VoIP on Android O+ selfManaged: true, foregroundService: { @@ -73,6 +76,12 @@ export class CallKeepService { // On Android, we might need to ask for permissions explicitly if not handled by setup if (Platform.Version >= 23) { + if (Platform.Version >= 30) { + const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS); + if (!hasPermission) { + await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS); + } + } RNCallKeep.setAvailable(true); }