wellnua-lite/utils/androidVoiceService.ts
Sergei 05f872d067 fix: voice session improvements - FAB stop, echo prevention, chat TTS
- FAB button now correctly stops session during speaking/processing states
- Echo prevention: STT stopped during TTS playback, results ignored during speaking
- Chat TTS only speaks when voice session is active (no auto-speak for text chat)
- Session stop now aborts in-flight API requests and prevents race conditions
- STT restarts after TTS with 800ms delay for audio focus release
- Pending interrupt transcript processed after TTS completion
- ChatContext added for message persistence across tab navigation
- VoiceFAB redesigned with state-based animations
- console.error replaced with console.warn across voice pipeline
- no-speech STT errors silenced (normal silence behavior)
2026-01-27 22:59:55 -08:00

269 lines
7.7 KiB
TypeScript

/**
* Android Voice Call Service
*
* Handles:
* 1. Foreground Service notification - keeps call alive in background
* 2. Battery Optimization check - warns user if optimization is enabled
*
* Only runs on Android - iOS handles background audio differently.
*/
import { Platform, Alert, Linking, NativeModules } from 'react-native';
// Notifee for foreground service
let notifee: any = null;
/**
* Lazy load notifee to avoid issues on iOS
*/
async function getNotifee() {
if (Platform.OS !== 'android') return null;
if (!notifee) {
try {
notifee = (await import('@notifee/react-native')).default;
} catch (e) {
console.warn('[AndroidVoiceService] Failed to load notifee:', e);
return null;
}
}
return notifee;
}
// Channel ID for voice call notifications
const CHANNEL_ID = 'voice-call-channel';
const NOTIFICATION_ID = 'voice-call-active';
/**
* Create notification channel (required for Android 8+)
*/
async function createNotificationChannel(): Promise<void> {
const notifeeModule = await getNotifee();
if (!notifeeModule) return;
try {
await notifeeModule.createChannel({
id: CHANNEL_ID,
name: 'Voice Calls',
description: 'Notifications for active voice calls with Julia AI',
importance: 4, // HIGH - shows notification but no sound
vibration: false,
sound: undefined,
});
console.log('[AndroidVoiceService] Notification channel created');
} catch (e) {
console.warn('[AndroidVoiceService] Failed to create channel:', e);
}
}
/**
* Start foreground service with notification
* Call this when voice call starts
*/
export async function startVoiceCallService(): Promise<void> {
if (Platform.OS !== 'android') {
console.log('[AndroidVoiceService] Skipping - not Android');
return;
}
console.log('[AndroidVoiceService] Starting foreground service...');
const notifeeModule = await getNotifee();
if (!notifeeModule) {
console.log('[AndroidVoiceService] Notifee not available');
return;
}
try {
// Create channel first
await createNotificationChannel();
// Display foreground service notification
await notifeeModule.displayNotification({
id: NOTIFICATION_ID,
title: 'Julia AI - Call Active',
body: 'Voice call in progress. Tap to return to the app.',
android: {
channelId: CHANNEL_ID,
asForegroundService: true,
ongoing: true, // Can't be swiped away
autoCancel: false,
smallIcon: 'ic_notification', // Uses default if not found
color: '#22c55e', // Green color
pressAction: {
id: 'default',
launchActivity: 'default',
},
// Important for keeping audio alive
importance: 4, // HIGH
category: 2, // CATEGORY_CALL
},
});
console.log('[AndroidVoiceService] Foreground service started');
} catch (e) {
console.warn('[AndroidVoiceService] Failed to start foreground service:', e);
}
}
/**
* Stop foreground service
* Call this when voice call ends
*/
export async function stopVoiceCallService(): Promise<void> {
if (Platform.OS !== 'android') return;
console.log('[AndroidVoiceService] Stopping foreground service...');
const notifeeModule = await getNotifee();
if (!notifeeModule) return;
try {
await notifeeModule.stopForegroundService();
await notifeeModule.cancelNotification(NOTIFICATION_ID);
console.log('[AndroidVoiceService] Foreground service stopped');
} catch (e) {
console.warn('[AndroidVoiceService] Failed to stop foreground service:', e);
}
}
/**
* Check if battery optimization is disabled for our app
* Returns true if optimization is DISABLED (good for us)
* Returns false if optimization is ENABLED (bad - system may kill our app)
*/
export async function isBatteryOptimizationDisabled(): Promise<boolean> {
if (Platform.OS !== 'android') {
return true; // iOS doesn't need this
}
try {
const notifeeModule = await getNotifee();
if (!notifeeModule) return true; // Assume OK if can't check
// Notifee provides a way to check power manager settings
const powerManagerInfo = await notifeeModule.getPowerManagerInfo();
// If device has power manager restrictions
if (powerManagerInfo.activity) {
return false; // Battery optimization is likely enabled
}
return true;
} catch (e) {
console.log('[AndroidVoiceService] Could not check battery optimization:', e);
return true; // Assume OK on error
}
}
/**
* Open battery optimization settings for our app
*/
export async function openBatteryOptimizationSettings(): Promise<void> {
if (Platform.OS !== 'android') return;
try {
const notifeeModule = await getNotifee();
if (notifeeModule) {
// Try to open power manager settings via notifee
await notifeeModule.openPowerManagerSettings();
return;
}
} catch (e) {
console.log('[AndroidVoiceService] Notifee openPowerManagerSettings failed:', e);
}
// Fallback: try to open battery optimization settings directly
try {
// Try generic battery settings
await Linking.openSettings();
} catch (e) {
console.warn('[AndroidVoiceService] Failed to open settings:', e);
}
}
/**
* Show alert about battery optimization
* Call this before starting a voice call on Android
*/
export function showBatteryOptimizationAlert(): void {
if (Platform.OS !== 'android') return;
Alert.alert(
'Optimize for Voice Calls',
'To ensure voice calls continue working when the app is in the background, please disable battery optimization for WellNuo.\n\nThis prevents Android from stopping the call when you switch apps or lock your screen.',
[
{
text: 'Later',
style: 'cancel',
},
{
text: 'Open Settings',
onPress: () => openBatteryOptimizationSettings(),
},
],
{ cancelable: true }
);
}
/**
* Check battery optimization and show alert if needed
* Returns true if we should proceed with the call
* Returns false if user chose to go to settings (call should be postponed)
*/
export async function checkAndPromptBatteryOptimization(): Promise<boolean> {
if (Platform.OS !== 'android') {
return true; // iOS - proceed
}
const isDisabled = await isBatteryOptimizationDisabled();
if (isDisabled) {
console.log('[AndroidVoiceService] Battery optimization already disabled - good!');
return true;
}
// Show alert and wait for user response
return new Promise((resolve) => {
Alert.alert(
'Optimize for Voice Calls',
'For reliable voice calls in the background, we recommend disabling battery optimization for WellNuo.\n\nWould you like to adjust this setting now?',
[
{
text: 'Skip for Now',
style: 'cancel',
onPress: () => resolve(true), // Proceed anyway
},
{
text: 'Open Settings',
onPress: async () => {
await openBatteryOptimizationSettings();
resolve(false); // Don't start call - user went to settings
},
},
],
{ cancelable: false }
);
});
}
/**
* Request notification permission (required for Android 13+)
*/
export async function requestNotificationPermission(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
const notifeeModule = await getNotifee();
if (!notifeeModule) return false;
try {
const settings = await notifeeModule.requestPermission();
const granted = settings.authorizationStatus >= 1; // AUTHORIZED or PROVISIONAL
console.log('[AndroidVoiceService] Notification permission:', granted ? 'granted' : 'denied');
return granted;
} catch (e) {
console.warn('[AndroidVoiceService] Failed to request notification permission:', e);
return false;
}
}