4d0b9e0d78
Bug 1 — dB-Range erweitert: VAD_SILENCE_DB_MIN von -55 auf -85 dB. Damit hat Stefan einen weiten Regler-Spielraum wenn die adaptive Auto-Erkennung in seiner Umgebung nicht zuverlaessig greift. Bug 5 — Mute-Button stoppt laufende TTS nicht: audioService bekommt jetzt einen internen _muted-Flag. handlePcmChunk setzt silent automatisch wenn _muted true ist, playAudio kehrt frueh zurueck. Verhindert Race zwischen User-Klick auf Mute und einem TTS-Chunk der im selben JS-Tick ankommt (vorher: Ref-Update via useEffect erst nach dem Re-Render → Chunks "rutschten durch"). Plus ttsCanPlayRef wird im toggleMute-Handler synchron aktualisiert. Bug 4 — VoIP/Messenger-Anrufe erkennen: AudioFocusModule emittiert jetzt "AudioFocusChanged" Events mit type "loss"/"loss_transient"/"gain". WhatsApp/Signal/Discord/etc. requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir fangen das in phoneCall.ts ab und rufen halt + pauseForCall genau wie beim klassischen Anruf. Plus getMode() Polling-Fallback (alle 3s) weil GAIN nicht zuverlaessig kommt wenn wir den Focus selbst released haben — sobald AudioMode wieder NORMAL ist, resumeFromCall. Bug 6 — Bilder als "Strich": attachmentImage hatte width: '100%' in einer Bubble mit maxWidth: '80%' ohne explizite Parent-Breite → RN rendert auf 0px Breite. Neue ChatImage- Komponente nutzt Image.getSize um die echte aspectRatio zu messen + setzt sie dynamisch. Bubble passt sich dem Bild an. Bugs 2 (lange Texte mid-cutoff) + 3 (Spotify resumed) — brauchen ADB-Logs. ADB-WLAN ueber 192.168.177.22:5555 schlaegt fehl (refused) — bei Android 11+ braucht's Wireless-Debugging-Pairing-Code. Stefan kann den nennen sobald er soweit ist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
7.1 KiB
TypeScript
192 lines
7.1 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 das Mikro/die Audio-Pipeline
|
|
* uebernommen — typisch VoIP-Apps bei eingehendem Anruf, aber auch System-
|
|
* Voice-Assistants etc. */
|
|
private _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): void {
|
|
if (type === 'loss' || type === 'loss_transient') {
|
|
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
|
|
if (this.lastState === 'ringing' || this.lastState === 'offhook') 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 {
|
|
audioService.haltAllPlayback(toast);
|
|
wakeWordService.pauseForCall().catch(() => {});
|
|
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
|
}
|
|
|
|
private _resumeAfterCall(toast: string): void {
|
|
wakeWordService.resumeFromCall().catch(() => {});
|
|
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
|
}
|
|
}
|
|
|
|
const phoneCallService = new PhoneCallService();
|
|
export default phoneCallService;
|