8f64f8fb30
Wenn ARIA's Resume-Pfad direkt nach Anruf-Ende den AudioFocus requestet, kollidiert das mit Spotify's eigenem Auto-Resume. System haengt noch im IN_CALL-Mode-Uebergang, Spotify sieht "Loss → Loss" und bleibt pausiert statt kurz zu resumen. Mit 800ms-Delay: Spotify schafft den Resume-Schritt, dann pausiert ARIA wieder ordnungsgemaess. Wenn ARIA nichts pending hatte, bleibt Spotify einfach weiter an. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
8.8 KiB
TypeScript
223 lines
8.8 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;
|
|
|
|
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),
|
|
);
|
|
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<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;
|
|
}
|
|
|
|
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;
|