/** * PhoneCall-Service — pausiert ARIA bei Telefonaten: * * 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt) * Status: idle / ringing / offhook * * 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via * AudioFocus-Loss-Event (AudioFocusModule.kt). Diese Apps requestn * AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir * bekommen ein "loss" Event und reagieren genauso wie auf RINGING. * * In beiden Faellen wird audioService.haltAllPlayback() + wakeWordService. * pauseForCall() gerufen. Bei call-end (idle / focus-gain) → resumeFromCall. * * Permission READ_PHONE_STATE ist nur fuer Pfad 1 noetig — Pfad 2 braucht * keine extra Berechtigung weil unser eigener AudioFocus-Listener feuert. */ import { NativeEventEmitter, NativeModules, PermissionsAndroid, Platform, ToastAndroid, } from 'react-native'; import audioService from './audio'; import wakeWordService from './wakeword'; interface PhoneCallNative { start(): Promise; stop(): Promise; } const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative }; type PhoneState = 'idle' | 'ringing' | 'offhook'; class PhoneCallService { private started: boolean = false; private subscription: { remove: () => void } | null = null; private focusSubscription: { remove: () => void } | null = null; private lastState: PhoneState = 'idle'; /** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch * TelephonyManager-IDLE-Event kommt. */ private interruptedByFocus: boolean = false; async start(): Promise { if (this.started || Platform.OS !== 'android') return false; // 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp, // Signal, Discord etc.) abdecken, brauchen keine Permission. try { const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any); this.focusSubscription = focusEmitter.addListener( 'AudioFocusChanged', (e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type), ); console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)'); } catch (err: any) { console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err); } // 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe if (PhoneCall) { try { const granted = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { title: 'ARIA Cockpit — Anruf-Erkennung', message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, ' + 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). ' + 'Es werden keine Anrufdaten gelesen oder gespeichert.', buttonPositive: 'Erlauben', buttonNegative: 'Spaeter', }, ); if (granted === PermissionsAndroid.RESULTS.GRANTED) { const ok = await PhoneCall.start(); if (ok) { const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any); this.subscription = emitter.addListener( 'PhoneCallStateChanged', (e: { state: PhoneState }) => this._onStateChanged(e.state), ); console.log('[PhoneCall] TelephonyManager-Listener aktiv'); } } else { console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt'); } } catch (err: any) { console.warn('[PhoneCall] TelephonyManager-Setup gescheitert:', err?.message || err); } } this.started = true; return true; } async stop(): Promise { if (!this.started) return; try { this.subscription?.remove(); } catch {} try { this.focusSubscription?.remove(); } catch {} this.subscription = null; this.focusSubscription = null; if (PhoneCall) { try { await PhoneCall.stop(); } catch {} } this.started = false; this.lastState = 'idle'; this.interruptedByFocus = false; } private _onStateChanged(state: PhoneState): void { if (state === this.lastState) return; const prev = this.lastState; console.log('[PhoneCall] State: %s → %s', prev, state); this.lastState = state; if (state === 'ringing' || state === 'offhook') { this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert'); } else if (state === 'idle' && prev !== 'idle') { // Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen. // Der Focus-Gain-Event triggert das Resume. if (!this.interruptedByFocus) { this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv'); } } } /** AudioFocus-Loss = irgendeine andere App hat den Focus uebernommen. * Das passiert bei VoIP-Anrufen (was wir wollen) ABER auch bei normalen * Audio-Playern (anderer Player startet, Notification-Sound, sogar * unsere eigenen Sound-Calls beim Play-Button). Daher checken wir den * AudioMode — nur IN_CALL (2) oder IN_COMMUNICATION (3) zaehlt als Anruf. */ private async _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): Promise { if (type === 'loss' || type === 'loss_transient') { // Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln. if (this.lastState === 'ringing' || this.lastState === 'offhook') return; // Mode pruefen — nur echte Anrufe behandeln. let mode = -1; try { mode = await (NativeModules.AudioFocus as any)?.getMode?.(); } catch {} if (mode !== 2 && mode !== 3) { // NORMAL-Mode → kein Anruf (Stefan hat z.B. Play-Button gedrueckt // oder Spotify hat sich neu reingedraengelt). Keine Toasts. console.log('[PhoneCall] FOCUS_LOSS ignoriert (AudioMode=%d, kein Call)', mode); return; } this.interruptedByFocus = true; this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert'); // Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus // selbst beim halt → kein automatischer GAIN). AudioMode != IN_COMMUNICATION // = Call vorbei. this._startVoipResumePoll(); } else if (type === 'gain') { if (this.interruptedByFocus) { this.interruptedByFocus = false; this._stopVoipResumePoll(); this._resumeAfterCall('Audio frei — ARIA wieder aktiv'); } } } /** Polling-Fallback: alle 3s checken ob AudioMode wieder NORMAL ist. */ private voipPollTimer: ReturnType | null = null; private _startVoipResumePoll(): void { if (this.voipPollTimer) return; this.voipPollTimer = setInterval(async () => { if (!this.interruptedByFocus) { this._stopVoipResumePoll(); return; } try { const mode = await (NativeModules.AudioFocus as any)?.getMode?.(); // 0 = MODE_NORMAL — Call ist vorbei if (typeof mode === 'number' && mode === 0) { this.interruptedByFocus = false; this._stopVoipResumePoll(); this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv'); } } catch {} }, 3000); } private _stopVoipResumePoll(): void { if (this.voipPollTimer) { clearInterval(this.voipPollTimer); this.voipPollTimer = null; } } private _haltForCall(toast: string): void { // Position merken bevor wir den Stream killen — fuer Auto-Resume. audioService.captureInterruption(); // pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben, // weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt. audioService.pauseForCall(toast); wakeWordService.pauseForCall().catch(() => {}); ToastAndroid.show(toast, ToastAndroid.SHORT); } private _resumeAfterCall(toast: string): void { // Anruf-Pause aufheben — neue Chunks duerfen wieder direkt abgespielt // werden (falls die Bridge mid-Anruf isFinal noch nicht geschickt hat). audioService.endCallPause(); wakeWordService.resumeFromCall().catch(() => {}); ToastAndroid.show(toast, ToastAndroid.SHORT); // 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus- // Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach // dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus- // Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert. // Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs- // gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an. setTimeout(() => { audioService.resumeFromInterruption(30000).then(ok => { if (ok) { console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet'); } }).catch(() => {}); }, 800); } } const phoneCallService = new PhoneCallService(); export default phoneCallService;