diff --git a/android/android/app/src/main/AndroidManifest.xml b/android/android/app/src/main/AndroidManifest.xml
index b287d8a..9ff5098 100644
--- a/android/android/app/src/main/AndroidManifest.xml
+++ b/android/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,8 @@
+
+
= Build.VERSION_CODES.S) {
+ val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
+ override fun onCallStateChanged(state: Int) {
+ handleStateChange(state)
+ }
+ }
+ tm.registerTelephonyCallback(reactApplicationContext.mainExecutor, cb)
+ modernCallback = cb
+ } else {
+ @Suppress("DEPRECATION")
+ val l = object : PhoneStateListener() {
+ override fun onCallStateChanged(state: Int, phoneNumber: String?) {
+ handleStateChange(state)
+ }
+ }
+ @Suppress("DEPRECATION")
+ tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
+ legacyListener = l
+ }
+ Log.i(TAG, "PhoneCall-Listener aktiv")
+ promise.resolve(true)
+ } catch (e: Exception) {
+ Log.e(TAG, "start fehlgeschlagen", e)
+ promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
+ }
+ }
+
+ @ReactMethod
+ fun stop(promise: Promise) {
+ try {
+ val tm = telephonyManager
+ if (tm != null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ (modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
+ modernCallback = null
+ } else {
+ @Suppress("DEPRECATION")
+ legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
+ legacyListener = null
+ }
+ }
+ telephonyManager = null
+ lastState = TelephonyManager.CALL_STATE_IDLE
+ promise.resolve(true)
+ } catch (e: Exception) {
+ promise.reject("STOP_FAILED", e.message ?: "")
+ }
+ }
+
+ private fun handleStateChange(state: Int) {
+ if (state == lastState) return
+ lastState = state
+ val name = when (state) {
+ TelephonyManager.CALL_STATE_RINGING -> "ringing"
+ TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
+ TelephonyManager.CALL_STATE_IDLE -> "idle"
+ else -> return
+ }
+ Log.i(TAG, "Telefon-State: $name")
+ val params = Arguments.createMap().apply { putString("state", name) }
+ try {
+ reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ .emit("PhoneCallStateChanged", params)
+ } catch (e: Exception) {
+ Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
+ }
+ }
+
+ @ReactMethod fun addListener(eventName: String) {}
+ @ReactMethod fun removeListeners(count: Int) {}
+}
diff --git a/android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.kt b/android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.kt
new file mode 100644
index 0000000..7fe6a01
--- /dev/null
+++ b/android/android/app/src/main/java/com/ariacockpit/PhoneCallPackage.kt
@@ -0,0 +1,16 @@
+package com.ariacockpit
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class PhoneCallPackage : ReactPackage {
+ override fun createNativeModules(reactContext: ReactApplicationContext): List {
+ return listOf(PhoneCallModule(reactContext))
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> {
+ return emptyList()
+ }
+}
diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx
index be62d97..5be0fe6 100644
--- a/android/src/screens/ChatScreen.tsx
+++ b/android/src/screens/ChatScreen.tsx
@@ -25,6 +25,7 @@ import RNFS from 'react-native-fs';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
+import phoneCallService from '../services/phoneCall';
import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
@@ -159,10 +160,23 @@ const ChatScreen: React.FC = () => {
const unsub = wakeWordService.onStateChange((s) => {
setWakeWordState(s);
setWakeWordActive(s !== 'off');
+ // Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
+ // Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
+ // Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
+ // 'armed' oder 'off' fallen, darf Spotify wieder.
+ if (s === 'conversing') audioService.acquireConversationFocus();
+ else audioService.releaseConversationFocus();
});
return () => unsub();
}, []);
+ // Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt
+ useEffect(() => {
+ phoneCallService.start().catch(err =>
+ console.warn('[Chat] phoneCall.start fehlgeschlagen', err));
+ return () => { phoneCallService.stop().catch(() => {}); };
+ }, []);
+
// ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
// darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
useEffect(() => {
diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts
index e10a570..bd197c0 100644
--- a/android/src/services/audio.ts
+++ b/android/src/services/audio.ts
@@ -198,6 +198,12 @@ class AudioService {
private focusReleaseTimer: ReturnType | null = null;
private readonly FOCUS_RELEASE_DELAY_MS = 800;
+ // Conversation-Mode: solange aktiv (Wake-Word Status 'conversing' ODER
+ // wir wissen "ARIA spricht gerade in einem Multi-Turn-Dialog"), halten wir
+ // den AudioFocus DAUERHAFT. Der per-Stream-Release wird unterdrueckt,
+ // damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt.
+ private _conversationFocusActive: boolean = false;
+
// VAD State
private vadEnabled: boolean = false;
private lastSpeechTime: number = 0;
@@ -214,11 +220,18 @@ class AudioService {
/** AudioFocus mit kleiner Verzoegerung freigeben — Spotify/YouTube
* springen sonst im Gap zwischen zwei TTS-Streams (oder wenn ARIA
- * eine zweite Antwort direkt hinterherschickt) kurz wieder an. */
+ * eine zweite Antwort direkt hinterherschickt) kurz wieder an.
+ * Im Conversation-Mode (Wake-Word conversing) wird das Release komplett
+ * unterdrueckt — der Focus bleibt fuer die ganze Konversation gehalten. */
private _releaseFocusDeferred(): void {
+ if (this._conversationFocusActive) {
+ this._cancelDeferredFocusRelease();
+ return;
+ }
this._cancelDeferredFocusRelease();
this.focusReleaseTimer = setTimeout(() => {
this.focusReleaseTimer = null;
+ if (this._conversationFocusActive) return;
AudioFocus?.release().catch(() => {});
}, this.FOCUS_RELEASE_DELAY_MS);
}
@@ -230,6 +243,33 @@ class AudioService {
}
}
+ /** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt
+ * pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */
+ acquireConversationFocus(): void {
+ if (this._conversationFocusActive) return;
+ this._conversationFocusActive = true;
+ this._cancelDeferredFocusRelease();
+ console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)');
+ AudioFocus?.requestDuck().catch(() => {});
+ }
+
+ /** Conversation-Mode endet → Focus darf wieder freigegeben werden
+ * (verzoegert, damit eine direkt folgende Antwort nichts kaputtmacht). */
+ releaseConversationFocus(): void {
+ if (!this._conversationFocusActive) return;
+ this._conversationFocusActive = false;
+ console.log('[Audio] Conversation-Focus inaktiv');
+ this._releaseFocusDeferred();
+ }
+
+ /** TTS-Wiedergabe haart stoppen — z.B. wenn ein Anruf reinkommt.
+ * Released auch sofort den AudioFocus damit der Anruf-Klingelton hoerbar ist. */
+ haltAllPlayback(reason: string = ''): void {
+ console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)');
+ this._conversationFocusActive = false;
+ this.stopPlayback();
+ }
+
// --- Berechtigungen ---
async requestMicrophonePermission(): Promise {
diff --git a/android/src/services/phoneCall.ts b/android/src/services/phoneCall.ts
new file mode 100644
index 0000000..b5adba9
--- /dev/null
+++ b/android/src/services/phoneCall.ts
@@ -0,0 +1,108 @@
+/**
+ * PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt
+ * oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
+ *
+ * Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen —
+ * ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
+ * (Audio kommt nicht zurueck), der User muesste die Antwort manuell
+ * nochmal anfordern (Play-Button auf der Nachricht).
+ *
+ * Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden —
+ * wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
+ */
+
+import {
+ NativeEventEmitter,
+ NativeModules,
+ PermissionsAndroid,
+ Platform,
+ ToastAndroid,
+} from 'react-native';
+import audioService from './audio';
+
+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 lastState: PhoneState = 'idle';
+
+ async start(): Promise {
+ if (this.started || !PhoneCall) return false;
+ if (Platform.OS !== 'android') return false;
+
+ // Runtime-Permission holen (nur einmal noetig)
+ 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) {
+ console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
+ return false;
+ }
+ } catch (err) {
+ console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
+ }
+
+ try {
+ const ok = await PhoneCall.start();
+ if (!ok) {
+ console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
+ return false;
+ }
+ const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
+ this.subscription = emitter.addListener('PhoneCallStateChanged', (e: { state: PhoneState }) => {
+ this._onStateChanged(e.state);
+ });
+ this.started = true;
+ console.log('[PhoneCall] Listener aktiv');
+ return true;
+ } catch (err: any) {
+ console.warn('[PhoneCall] start gescheitert:', err?.message || err);
+ return false;
+ }
+ }
+
+ async stop(): Promise {
+ if (!this.started || !PhoneCall) return;
+ try {
+ this.subscription?.remove();
+ this.subscription = null;
+ await PhoneCall.stop();
+ } catch {}
+ this.started = false;
+ this.lastState = 'idle';
+ }
+
+ private _onStateChanged(state: PhoneState): void {
+ if (state === this.lastState) return;
+ console.log('[PhoneCall] State: %s → %s', this.lastState, state);
+ this.lastState = state;
+ if (state === 'ringing' || state === 'offhook') {
+ audioService.haltAllPlayback(`Telefon-State: ${state}`);
+ ToastAndroid.show(
+ state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert',
+ ToastAndroid.SHORT,
+ );
+ }
+ // idle: nichts automatisch — User soll nichts unbeabsichtigt re-triggern
+ }
+}
+
+const phoneCallService = new PhoneCallService();
+export default phoneCallService;