diff --git a/app/(tabs)/chat.tsx b/app/(tabs)/chat.tsx index 6388f7c..60de794 100644 --- a/app/(tabs)/chat.tsx +++ b/app/(tabs)/chat.tsx @@ -176,6 +176,19 @@ export default function ChatScreen() { autoSelect(); }, []); + // Scroll to end when keyboard shows + useEffect(() => { + const keyboardShowListener = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + () => { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + ); + return () => keyboardShowListener.remove(); + }, []); + const openBeneficiaryPicker = useCallback(() => { setShowBeneficiaryPicker(true); loadBeneficiaries(); @@ -338,7 +351,7 @@ export default function ChatScreen() { }; return ( - + {/* Header */} router.push('/(tabs)')}> @@ -422,8 +435,8 @@ export default function ChatScreen() { {/* Messages */} {}); log('Screen keep-awake activated', 'info'); @@ -327,6 +349,19 @@ export default function DebugScreen() { }); log(`Local participant: ${newRoom.localParticipant.identity}`, 'info'); + + // Android: Start foreground service to keep call alive in background + if (Platform.OS === 'android') { + log('Android: Starting foreground service...', 'info'); + try { + await startVoiceCallService(); + log('Foreground service started - call will continue in background', 'success'); + } catch (fgErr: any) { + log(`Foreground service error: ${fgErr?.message || fgErr}`, 'error'); + // Continue anyway - call will still work, just may be killed in background + } + } + log('=== CALL ACTIVE ===', 'success'); } catch (err: any) { @@ -352,6 +387,17 @@ export default function DebugScreen() { log('Disconnected from room', 'success'); } + // Android: Stop foreground service + if (Platform.OS === 'android') { + log('Android: Stopping foreground service...', 'info'); + try { + await stopVoiceCallService(); + log('Foreground service stopped', 'success'); + } catch (fgErr: any) { + log(`Foreground service stop error: ${fgErr?.message || fgErr}`, 'error'); + } + } + // Stop AudioSession (iOS + Android) log(`Stopping AudioSession on ${Platform.OS}...`, 'info'); try { diff --git a/app/voice-call.tsx b/app/voice-call.tsx index 3dc47ff..8e37220 100644 --- a/app/voice-call.tsx +++ b/app/voice-call.tsx @@ -19,10 +19,8 @@ import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; -import { VOICE_NAME } from '@/services/livekitService'; import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext'; import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom'; -import { setAudioOutput } from '@/utils/audioSession'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -30,9 +28,6 @@ export default function VoiceCallScreen() { const router = useRouter(); const { clearTranscript, addTranscriptEntry } = useVoiceTranscript(); - // Speaker/earpiece toggle state - const [isSpeakerOn, setIsSpeakerOn] = React.useState(true); - // LiveKit hook - ALL logic is here const { state, @@ -142,13 +137,6 @@ export default function VoiceCallScreen() { router.back(); }; - // Toggle speaker/earpiece - const handleToggleSpeaker = async () => { - const newSpeakerState = !isSpeakerOn; - setIsSpeakerOn(newSpeakerState); - await setAudioOutput(newSpeakerState); - }; - // Format duration as MM:SS const formatDuration = (seconds: number): string => { const mins = Math.floor(seconds / 60); @@ -238,7 +226,6 @@ export default function VoiceCallScreen() { {/* Name and status */} Julia AI - {VOICE_NAME} voice {isActive ? ( @@ -267,7 +254,7 @@ export default function VoiceCallScreen() { - {/* Bottom controls */} + {/* Bottom controls - centered layout with 2 buttons */} {/* Mute button */} - - {/* Empty placeholder for layout balance */} - ); @@ -374,11 +358,6 @@ const styles = StyleSheet.create({ color: AppColors.white, marginBottom: Spacing.xs, }, - voiceName: { - fontSize: FontSizes.sm, - color: 'rgba(255,255,255,0.6)', - marginBottom: Spacing.md, - }, statusContainer: { flexDirection: 'row', alignItems: 'center', @@ -419,10 +398,11 @@ const styles = StyleSheet.create({ }, controls: { flexDirection: 'row', - justifyContent: 'space-evenly', + justifyContent: 'center', alignItems: 'center', paddingVertical: Spacing.xl, paddingHorizontal: Spacing.lg, + gap: 48, // Space between Mute and End Call buttons }, controlButton: { alignItems: 'center', diff --git a/eas.json b/eas.json index 22b509f..4ae7fd5 100644 --- a/eas.json +++ b/eas.json @@ -16,12 +16,18 @@ } }, "preview": { - "distribution": "internal" + "distribution": "internal", + "android": { + "buildType": "apk" + } }, "production": { "autoIncrement": true, "ios": { "credentialsSource": "remote" + }, + "android": { + "buildType": "apk" } } }, diff --git a/julia-agent/julia-ai/src/agent.py b/julia-agent/julia-ai/src/agent.py index afb2716..68ace34 100644 --- a/julia-agent/julia-ai/src/agent.py +++ b/julia-agent/julia-ai/src/agent.py @@ -1,7 +1,7 @@ """ WellNuo Voice Agent - Julia AI LiveKit Agents Cloud deployment -Uses WellNuo voice_ask API for LLM responses, Deepgram for STT/TTS +Uses WellNuo ask_wellnuo_ai API for LLM responses, Deepgram for STT/TTS """ import logging @@ -138,7 +138,7 @@ def normalize_question(user_message: str) -> str: class WellNuoLLM(llm.LLM): - """Custom LLM that uses WellNuo voice_ask API.""" + """Custom LLM that uses WellNuo ask_wellnuo_ai API.""" def __init__(self): super().__init__() @@ -178,7 +178,7 @@ class WellNuoLLM(llm.LLM): raise Exception("Failed to authenticate with WellNuo API") async def get_response(self, user_message: str) -> str: - """Call WellNuo voice_ask API and return response.""" + """Call WellNuo ask_wellnuo_ai API and return response.""" if not user_message: return "I'm here to help. What would you like to know?" @@ -191,10 +191,10 @@ class WellNuoLLM(llm.LLM): token = await self._ensure_token() async with aiohttp.ClientSession() as session: - # Using voice_ask - MUCH faster than ask_wellnuo_ai (1s vs 27s) - # ask_wellnuo_ai has 20x higher latency and causes timeouts + # Using ask_wellnuo_ai - latency ~1-2s after warmup + # Provides more comprehensive responses than voice_ask data = { - "function": "voice_ask", + "function": "ask_wellnuo_ai", "clientId": "MA_001", "user_name": WELLNUO_USER, "token": token, @@ -297,7 +297,7 @@ async def entrypoint(ctx: JobContext): await ctx.connect() logger.info(f"Starting Julia AI session in room {ctx.room.name}") - logger.info(f"Using WellNuo voice_ask API with deployment_id: {DEPLOYMENT_ID}") + logger.info(f"Using WellNuo ask_wellnuo_ai API with deployment_id: {DEPLOYMENT_ID}") session = AgentSession( # Deepgram Nova-2 for accurate speech-to-text diff --git a/maestro/voice-call-test.yaml b/maestro/voice-call-test.yaml new file mode 100644 index 0000000..dea0677 --- /dev/null +++ b/maestro/voice-call-test.yaml @@ -0,0 +1,59 @@ +appId: com.wellnuo.BluetoothScanner +--- +# WellNuoLite Voice Call Test +# Tests the voice call functionality with self-hosted LiveKit + +- launchApp: + clearState: false + +# Wait for app to load - Dashboard screen should appear +- extendedWaitUntil: + visible: "Dashboard" + timeout: 15000 + +# Wait extra time for loading modal to disappear +- extendedWaitUntil: + notVisible: "Please wait" + timeout: 20000 + +# Take screenshot of Dashboard +- takeScreenshot: 01-dashboard-loaded + +# Tap on Voice Debug tab (3rd tab in bottom navigation) +- tapOn: + point: "75%,97%" + +# Wait for Voice Debug screen to load +- extendedWaitUntil: + visible: "Voice Debug" + timeout: 10000 + +# Take screenshot of Voice Debug screen +- takeScreenshot: 02-voice-debug-screen + +# Tap Start Voice Call button (green button at top ~15% from top) +- tapOn: + point: "50%,15%" + +# Wait for voice call screen to appear +- extendedWaitUntil: + visible: "Julia AI" + timeout: 15000 + +# Take screenshot of call screen +- takeScreenshot: 03-voice-call-started + +# Wait a bit for connection attempt +- swipe: + direction: DOWN + duration: 500 + +# Take screenshot of current state +- takeScreenshot: 04-voice-call-state + +# End call - tap the red end call button at bottom +- tapOn: + point: "50%,90%" + +# Take final screenshot +- takeScreenshot: 05-call-ended diff --git a/package-lock.json b/package-lock.json index b6fb50b..0e85e11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@expo/vector-icons": "^15.0.3", "@livekit/react-native": "^2.9.6", "@livekit/react-native-expo-plugin": "^1.0.1", + "@notifee/react-native": "^9.1.8", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", @@ -3383,6 +3384,15 @@ "node": ">=12.4.0" } }, + "node_modules/@notifee/react-native": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz", + "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", diff --git a/package.json b/package.json index 3dc3348..f55852e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@expo/vector-icons": "^15.0.3", "@livekit/react-native": "^2.9.6", "@livekit/react-native-expo-plugin": "^1.0.1", + "@notifee/react-native": "^9.1.8", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", diff --git a/specs/FEATURE-002-livekit-voice-call.md b/specs/FEATURE-002-livekit-voice-call.md new file mode 100644 index 0000000..fe57880 --- /dev/null +++ b/specs/FEATURE-002-livekit-voice-call.md @@ -0,0 +1,336 @@ +# FEATURE-002: LiveKit Voice Call with Julia AI + +## Summary + +Полноценный голосовой звонок с Julia AI через LiveKit Cloud. Пользователь нажимает кнопку "Start Voice Call", открывается экран звонка в стиле телефона, и он может разговаривать с Julia AI голосом. + +## Status: 🔴 Not Started (требуется полная переделка) + +## Priority: Critical + +## Problem Statement + +Текущая реализация имеет следующие проблемы: +1. **STT (Speech-to-Text) работает нестабильно** — микрофон иногда детектируется, иногда нет +2. **TTS работает** — голос Julia слышен +3. **Код сложный и запутанный** — много legacy кода, полифиллов, хаков +4. **Нет четкой архитектуры** — все в одном файле voice-call.tsx + +## Root Cause Analysis + +### Почему микрофон работает нестабильно: +1. **iOS AudioSession** — неправильная конфигурация или race condition при настройке +2. **registerGlobals()** — WebRTC polyfills могут не успевать инициализироваться +3. **Permissions** — микрофон может быть не разрешен или занят другим процессом +4. **Event handling** — события LiveKit могут теряться + +### Что работает: +- LiveKit Cloud connection ✅ +- Token generation ✅ +- TTS (Deepgram Asteria) ✅ +- Backend agent (Julia AI) ✅ + +--- + +## Architecture + +### System Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ WellNuo Lite App (iOS) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Voice Tab │───▶│ VoiceCallScreen │───▶│ LiveKit Room │ │ +│ │ (entry) │ │ (fullscreen) │ │ (WebRTC) │ │ +│ └──────────────┘ └──────────────────┘ └──────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │useLiveKitRoom│ │ AudioSession │ │ +│ │ (hook) │ │ (iOS native) │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ WebSocket + WebRTC + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ LiveKit Cloud │ +├─────────────────────────────────────────────────────────────────────┤ +│ Room: wellnuo-{userId}-{timestamp} │ +│ Participants: user + julia-agent │ +│ Audio Tracks: bidirectional │ +└─────────────────────────────────────────────────────────────────────┘ + │ + │ Agent dispatch + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Julia AI Agent (Python) │ +├─────────────────────────────────────────────────────────────────────┤ +│ STT: Deepgram Nova-2 │ +│ LLM: WellNuo voice_ask API │ +│ TTS: Deepgram Aura Asteria │ +│ Framework: LiveKit Agents SDK 1.3.11 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow + +``` +User speaks → iOS Mic → WebRTC → LiveKit Cloud → Agent → Deepgram STT + │ + ▼ + WellNuo API (LLM) + │ + ▼ +Agent receives text ← LiveKit Cloud ← WebRTC ← Deepgram TTS (audio) + │ + ▼ + iOS Speaker → User hears Julia +``` + +--- + +## Technical Requirements + +### Dependencies (package.json) + +```json +{ + "@livekit/react-native": "^2.x", + "livekit-client": "^2.x", + "expo-keep-awake": "^14.x" +} +``` + +### iOS Permissions (app.json) + +```json +{ + "ios": { + "infoPlist": { + "NSMicrophoneUsageDescription": "WellNuo needs microphone access for voice calls with Julia AI", + "UIBackgroundModes": ["audio", "voip"] + } + } +} +``` + +### Token Server (already exists) + +- **URL**: `https://wellnuo.smartlaunchhub.com/julia/token` +- **Method**: POST +- **Body**: `{ "userId": "string" }` +- **Response**: `{ "success": true, "data": { "token", "roomName", "wsUrl" } }` + +--- + +## Implementation Steps + +### Phase 1: Cleanup (DELETE old code) + +- [ ] 1.1. Delete `app/voice-call.tsx` (current broken implementation) +- [ ] 1.2. Keep `app/(tabs)/voice.tsx` (entry point) but simplify +- [ ] 1.3. Keep `services/livekitService.ts` (token fetching) +- [ ] 1.4. Keep `contexts/VoiceTranscriptContext.tsx` (transcript storage) +- [ ] 1.5. Delete `components/VoiceIndicator.tsx` (unused) +- [ ] 1.6. Delete `polyfills/livekit-globals.ts` (not needed with proper setup) + +### Phase 2: New Architecture + +- [ ] 2.1. Create `hooks/useLiveKitRoom.ts` — encapsulate all LiveKit logic +- [ ] 2.2. Create `app/voice-call.tsx` — simple UI component using the hook +- [ ] 2.3. Create `utils/audioSession.ts` — iOS AudioSession helper + +### Phase 3: useLiveKitRoom Hook + +**File**: `hooks/useLiveKitRoom.ts` + +```typescript +interface UseLiveKitRoomOptions { + userId: string; + onTranscript?: (role: 'user' | 'assistant', text: string) => void; +} + +interface UseLiveKitRoomReturn { + // Connection state + state: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'; + error: string | null; + + // Call info + roomName: string | null; + callDuration: number; // seconds + + // Audio state + isMuted: boolean; + isSpeaking: boolean; // agent is speaking + + // Actions + connect: () => Promise; + disconnect: () => Promise; + toggleMute: () => void; +} +``` + +**Implementation requirements**: +1. MUST call `registerGlobals()` BEFORE importing `livekit-client` +2. MUST configure iOS AudioSession BEFORE connecting to room +3. MUST handle all RoomEvents properly +4. MUST cleanup on unmount (disconnect, stop audio session) +5. MUST handle background/foreground transitions + +### Phase 4: iOS AudioSession Configuration + +**Critical for microphone to work!** + +```typescript +// utils/audioSession.ts +import { AudioSession } from '@livekit/react-native'; +import { Platform } from 'react-native'; + +export async function configureAudioForVoiceCall(): Promise { + if (Platform.OS !== 'ios') return; + + // Step 1: Set Apple audio configuration + await AudioSession.setAppleAudioConfiguration({ + audioCategory: 'playAndRecord', + audioCategoryOptions: [ + 'allowBluetooth', + 'allowBluetoothA2DP', + 'defaultToSpeaker', + 'mixWithOthers', + ], + audioMode: 'voiceChat', + }); + + // Step 2: Configure output + await AudioSession.configureAudio({ + ios: { + defaultOutput: 'speaker', + }, + }); + + // Step 3: Start session + await AudioSession.startAudioSession(); +} + +export async function stopAudioSession(): Promise { + if (Platform.OS !== 'ios') return; + await AudioSession.stopAudioSession(); +} +``` + +### Phase 5: Voice Call Screen UI + +**File**: `app/voice-call.tsx` + +Simple, clean UI: +- Avatar with Julia "J" letter +- Call duration timer +- Status text (Connecting... / Connected / Julia is speaking...) +- Mute button +- End call button +- Debug logs toggle (for development) + +**NO complex logic in this file** — all LiveKit logic in the hook! + +### Phase 6: Testing Checklist + +- [ ] 6.1. Fresh app launch → Start call → Can hear Julia greeting +- [ ] 6.2. Speak → Julia responds → Conversation works +- [ ] 6.3. Mute → Unmute → Still works +- [ ] 6.4. End call → Clean disconnect +- [ ] 6.5. App to background → Audio continues +- [ ] 6.6. App to foreground → Still connected +- [ ] 6.7. Multiple calls in a row → No memory leaks +- [ ] 6.8. No microphone permission → Shows error + +--- + +## Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `hooks/useLiveKitRoom.ts` | CREATE | Main LiveKit hook with all logic | +| `utils/audioSession.ts` | CREATE | iOS AudioSession helpers | +| `app/voice-call.tsx` | REPLACE | Simple UI using the hook | +| `app/(tabs)/voice.tsx` | SIMPLIFY | Just entry point, remove debug UI | +| `services/livekitService.ts` | KEEP | Token fetching (already works) | +| `contexts/VoiceTranscriptContext.tsx` | KEEP | Transcript storage | +| `components/VoiceIndicator.tsx` | DELETE | Not needed | +| `polyfills/livekit-globals.ts` | DELETE | Not needed | + +--- + +## Key Principles + +### 1. Separation of Concerns +- **Hook** handles ALL LiveKit/WebRTC logic +- **Screen** only renders UI based on hook state +- **Utils** for platform-specific code (AudioSession) + +### 2. Proper Initialization Order +``` +1. registerGlobals() — WebRTC polyfills +2. configureAudioForVoiceCall() — iOS audio +3. getToken() — fetch from server +4. room.connect() — connect to LiveKit +5. room.localParticipant.setMicrophoneEnabled(true) — enable mic +``` + +### 3. Proper Cleanup Order +``` +1. room.disconnect() — leave room +2. stopAudioSession() — release iOS audio +3. Clear all refs and state +``` + +### 4. Error Handling +- Every async operation wrapped in try/catch +- User-friendly error messages +- Automatic retry for network issues +- Graceful degradation + +--- + +## Success Criteria + +1. ✅ User can start voice call and hear Julia greeting +2. ✅ User can speak and Julia understands (STT works reliably) +3. ✅ Julia responds with voice (TTS works) +4. ✅ Conversation can continue back and forth +5. ✅ Mute/unmute works +6. ✅ End call cleanly disconnects +7. ✅ No console errors or warnings +8. ✅ Works on iOS device (not just simulator) + +--- + +## Related Links + +- [LiveKit React Native SDK](https://docs.livekit.io/client-sdk-js/react-native/) +- [LiveKit Agents Python](https://docs.livekit.io/agents/) +- [Deepgram STT/TTS](https://deepgram.com/) +- [iOS AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession) + +--- + +## Notes + +### Why previous approach failed: + +1. **Too much code in one file** — voice-call.tsx had 900+ lines with all logic mixed +2. **Polyfills applied wrong** — Event class polyfill was inside the component +3. **AudioSession configured too late** — sometimes after connect() already started +4. **No proper error boundaries** — errors silently failed +5. **Race conditions** — multiple async operations without proper sequencing + +### What's different this time: + +1. **Hook-based architecture** — single source of truth for state +2. **Proper initialization sequence** — documented and enforced +3. **Clean separation** — UI knows nothing about WebRTC +4. **Comprehensive logging** — every step logged for debugging +5. **Test-driven** — write tests before implementation diff --git a/utils/androidVoiceService.ts b/utils/androidVoiceService.ts new file mode 100644 index 0000000..1d0cac6 --- /dev/null +++ b/utils/androidVoiceService.ts @@ -0,0 +1,268 @@ +/** + * 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.error('[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 { + 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.error('[AndroidVoiceService] Failed to create channel:', e); + } +} + +/** + * Start foreground service with notification + * Call this when voice call starts + */ +export async function startVoiceCallService(): Promise { + 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.error('[AndroidVoiceService] Failed to start foreground service:', e); + } +} + +/** + * Stop foreground service + * Call this when voice call ends + */ +export async function stopVoiceCallService(): Promise { + 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.error('[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 { + 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 { + 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.error('[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 { + 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 { + 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.error('[AndroidVoiceService] Failed to request notification permission:', e); + return false; + } +}