0044e222db
Symptom: App bekommt im minimierten oder display-gesperrten Zustand nicht mit ob ein Anruf angefangen oder beendet wurde — TTS spricht weiter waehrend Telefon klingelt, oder bleibt stumm nach Auflegen. Zwei Ursachen: 1) Kotlin: TelephonyCallback war auf reactApplicationContext.mainExecutor registriert. Wenn die Activity pausiert ist (display aus, App im Hintergrund), wird der mainExecutor verzoegert oder gar nicht abgearbeitet — Call-State-Events kommen nicht durch. Fix: eigener Executors.newSingleThreadExecutor() — laeuft unabhaengig vom UI-Thread solange der App-Prozess lebt (Foreground-Service garantiert das). 2) TS: TelephonyManager-Listener kann nach laengerer Hintergrund-Zeit verloren gehen (React-Bridge-Context recreated nach Resume). Fix: neue refresh()-Methode in phoneCallService, AppState-Resume ruft sie auf — wenn telephonyAttached=false ist, wird der Native- Listener neu attached. Plus: Status-Property telephonyAttached macht in Logs sichtbar ob Pfad 1 (TelephonyManager) wirklich greift. Pfad 2 (AudioFocus fuer VoIP) war nie betroffen, der laeuft komplett im Native-Code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
/**
|
|
* 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<boolean>;
|
|
stop(): Promise<boolean>;
|
|
}
|
|
|
|
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;
|
|
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
|
|
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
|
|
private telephonyAttached: boolean = false;
|
|
|
|
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
|
|
status(): { focusAttached: boolean; telephonyAttached: boolean } {
|
|
return {
|
|
focusAttached: this.focusSubscription !== null,
|
|
telephonyAttached: this.telephonyAttached,
|
|
};
|
|
}
|
|
|
|
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
|
|
* TelephonyManager-Listener verloren ging (kann passieren wenn der
|
|
* React-Bridge-Context recreated wurde), neu attachen. */
|
|
async refresh(): Promise<void> {
|
|
if (!this.started) return;
|
|
if (this.telephonyAttached) return; // alles ok
|
|
if (!PhoneCall) return;
|
|
try {
|
|
const ok = await PhoneCall.start();
|
|
if (ok) {
|
|
if (!this.subscription) {
|
|
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
|
this.subscription = emitter.addListener(
|
|
'PhoneCallStateChanged',
|
|
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
|
);
|
|
}
|
|
this.telephonyAttached = true;
|
|
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
|
|
}
|
|
} catch (err: any) {
|
|
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
|
|
}
|
|
}
|
|
|
|
async start(): Promise<boolean> {
|
|
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),
|
|
);
|
|
this.telephonyAttached = true;
|
|
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
|
} else {
|
|
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht 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<void> {
|
|
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;
|
|
this.telephonyAttached = 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<void> {
|
|
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<typeof setInterval> | 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;
|