diff --git a/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt b/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt index e768902..2226bbe 100644 --- a/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt @@ -5,26 +5,71 @@ import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Build +import android.util.Log +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod +import com.facebook.react.modules.core.DeviceEventManagerModule /** - * Steuert Audio-Focus fuer Ducking/Muten anderer Apps. + * Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events + * an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert + * zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern + * requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen. * * - requestDuck() → andere Apps werden leiser (ARIA spricht TTS) * - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme) * - release() → Focus abgeben, andere Apps duerfen wieder + * + * Events: + * - "AudioFocusChanged" mit type: + * "loss" — endgueltiger Verlust (Anruf, andere App permanent) + * "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung) + * "gain" — Fokus zurueck */ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { override fun getName() = "AudioFocus" + companion object { private const val TAG = "AudioFocus" } + private var currentRequest: AudioFocusRequest? = null private fun audioManager(): AudioManager? = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private fun emitFocusChange(type: String) { + try { + val params = Arguments.createMap().apply { putString("type", type) } + reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("AudioFocusChanged", params) + } catch (e: Exception) { + Log.w(TAG, "emit failed: ${e.message}") + } + } + + private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)") + emitFocusChange("loss") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)") + emitFocusChange("loss_transient") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter + Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert") + } + AudioManager.AUDIOFOCUS_GAIN -> { + Log.i(TAG, "AUDIOFOCUS_GAIN") + emitFocusChange("gain") + } + } + } + private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) { val am = audioManager() if (am == null) { @@ -41,13 +86,13 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase .build() val req = AudioFocusRequest.Builder(durationHint) .setAudioAttributes(attrs) - .setOnAudioFocusChangeListener { /* kein Callback noetig */ } + .setOnAudioFocusChangeListener(focusListener) .build() currentRequest = req am.requestAudioFocus(req) } else { @Suppress("DEPRECATION") - am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint) + am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint) } promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) @@ -92,8 +137,24 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase currentRequest?.let { am.abandonAudioFocusRequest(it) } } else { @Suppress("DEPRECATION") - am.abandonAudioFocus(null) + am.abandonAudioFocus(focusListener) } currentRequest = null } + + /** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4. + * IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) — + * kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */ + @ReactMethod + fun getMode(promise: Promise) { + val am = audioManager() + if (am == null) { + promise.resolve(0) + return + } + promise.resolve(am.mode) + } + + @ReactMethod fun addListener(eventName: String) {} + @ReactMethod fun removeListeners(count: Int) {} } diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 0e1c1b9..43e363d 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -80,6 +80,45 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] => const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`; const STORAGE_PATH_KEY = 'aria_attachment_storage_path'; +/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via + * Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die + * Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */ +const CHAT_IMAGE_STYLE = { + width: 260, + borderRadius: 8, + marginBottom: 6, + backgroundColor: '#0D0D1A', +} as const; +const ChatImage: React.FC<{ + uri: string; + onPress: () => void; + onError: () => void; +}> = ({ uri, onPress, onError }) => { + const [aspectRatio, setAspectRatio] = useState(4 / 3); + useEffect(() => { + let cancelled = false; + Image.getSize(uri, (w, h) => { + if (!cancelled && w > 0 && h > 0) { + // Aspect-Ratio capen damit sehr lange Panorama-Bilder oder hohe + // Screenshot-Streifen die Bubble nicht sprengen + const r = Math.max(0.5, Math.min(2.5, w / h)); + setAspectRatio(r); + } + }, () => {}); + return () => { cancelled = true; }; + }, [uri]); + return ( + + + + ); +}; + async function getAttachmentDir(): Promise { try { const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY); @@ -154,7 +193,9 @@ const ChatScreen: React.FC = () => { const enabled = await AsyncStorage.getItem('aria_tts_enabled'); setTtsDeviceEnabled(enabled !== 'false'); // default true const muted = await AsyncStorage.getItem('aria_tts_muted'); - setTtsMuted(muted === 'true'); // default false + const isMuted = muted === 'true'; + setTtsMuted(isMuted); // default false + audioService.setMuted(isMuted); // service-internen Flag synchronisieren const voice = await AsyncStorage.getItem('aria_xtts_voice'); localXttsVoiceRef.current = voice || ''; ttsSpeedRef.current = await loadTtsSpeed(); @@ -229,11 +270,15 @@ const ChatScreen: React.FC = () => { setTtsMuted(prev => { const next = !prev; AsyncStorage.setItem('aria_tts_muted', String(next)); - // Bei Muten sofort laufende Wiedergabe stoppen - if (next) audioService.stopPlayback(); + // Ref synchron updaten — sonst kommen noch Chunks im selben Tick + // mit canPlay=true durch (Race vor dem useEffect-Update). + ttsCanPlayRef.current = ttsDeviceEnabled && !next; + // Globalen Mute-Flag im audioService setzen — uebersteuert auch + // payload.silent in handlePcmChunk und stoppt laufende Wiedergabe. + audioService.setMuted(next); return next; }); - }, []); + }, [ttsDeviceEnabled]); // Chat-Verlauf aus AsyncStorage laden const isInitialLoad = useRef(true); @@ -925,11 +970,9 @@ const ChatScreen: React.FC = () => { {item.attachments?.map((att, idx) => ( {att.type === 'image' && att.uri ? ( - setFullscreenImage(att.uri || null)} activeOpacity={0.8}> - setFullscreenImage(att.uri || null)} onError={() => { setMessages(prev => prev.map(m => m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) => @@ -938,7 +981,6 @@ const ChatScreen: React.FC = () => { )); }} /> - ) : att.type === 'image' && !att.uri ? ( { if (!base64Data) return; - + // Mute-Flag respektieren — robust gegen Race-Conditions zwischen User- + // Klick auf Mute und einem TTS-Chunk der im selben Tick eintrifft. + if (this._muted) return; this.audioQueue.push(base64Data); if (!this.isPlaying) { this._playNext(); @@ -677,7 +679,9 @@ class AudioService { final?: boolean; silent?: boolean; }): Promise { - const silent = !!payload.silent; + // Globaler Mute-Flag uebersteuert das per-Call silent — verhindert + // Race-Conditions wenn der User zwischen Chunks den Mute-Knopf drueckt. + const silent = !!payload.silent || this._muted; if (!silent && !PcmStreamPlayer) { console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar'); return ''; @@ -937,6 +941,17 @@ class AudioService { } } + /** Mute: alle eingehenden TTS-Chunks/WAVs werden ignoriert bis wieder + * unmuted. Robuster als ein React-Ref weil hier kein Re-Render-Race ist + * — die Bridge kann einen Chunk im selben JS-Tick liefern in dem der + * User Mute geklickt hat. */ + private _muted: boolean = false; + setMuted(muted: boolean): void { + this._muted = muted; + if (muted) this.stopPlayback(); + } + isMuted(): boolean { return this._muted; } + /** Laufende Wiedergabe stoppen + Queue leeren */ stopPlayback(): void { // Foreground-Service auch stoppen — sonst bleibt die Notification haengen diff --git a/android/src/services/phoneCall.ts b/android/src/services/phoneCall.ts index 6242387..91f41a8 100644 --- a/android/src/services/phoneCall.ts +++ b/android/src/services/phoneCall.ts @@ -1,14 +1,19 @@ /** - * PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt - * oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt. + * PhoneCall-Service — pausiert ARIA bei Telefonaten: * - * 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). + * 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt) + * Status: idle / ringing / offhook * - * Permission READ_PHONE_STATE muss vom Nutzer einmalig erteilt werden — - * wenn nicht, failed start() leise und der Rest funktioniert wie bisher. + * 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 { @@ -33,61 +38,76 @@ 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 || !PhoneCall) return false; - if (Platform.OS !== 'android') return false; + if (this.started || Platform.OS !== 'android') return false; - // Runtime-Permission holen (nur einmal noetig) + // 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp, + // Signal, Discord etc.) abdecken, brauchen keine Permission. 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', - }, + const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any); + this.focusSubscription = focusEmitter.addListener( + 'AudioFocusChanged', + (e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type), ); - if (granted !== PermissionsAndroid.RESULTS.GRANTED) { - console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt'); - return false; - } - } catch (err) { - console.warn('[PhoneCall] Permission-Anfrage gescheitert', err); + console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)'); + } catch (err: any) { + console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err); } - try { - const ok = await PhoneCall.start(); - if (!ok) { - console.warn('[PhoneCall] Native start() lieferte false (Permission?)'); - return false; + // 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); } - 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; } + + this.started = true; + return true; } async stop(): Promise { - if (!this.started || !PhoneCall) return; - try { - this.subscription?.remove(); - this.subscription = null; - await PhoneCall.stop(); - } catch {} + 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 { @@ -96,22 +116,75 @@ class PhoneCallService { console.log('[PhoneCall] State: %s → %s', prev, state); this.lastState = state; if (state === 'ringing' || state === 'offhook') { - audioService.haltAllPlayback(`Telefon-State: ${state}`); - // Wake-Word + Aufnahme pausieren: Telefonie-App belegt das Mikro - // waehrend des Anrufs, plus ARIA soll nicht im Telefonat zuhoeren. - wakeWordService.pauseForCall().catch(() => {}); - ToastAndroid.show( - state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert', - ToastAndroid.SHORT, - ); + this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert'); } else if (state === 'idle' && prev !== 'idle') { - // Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war. - // TTS kommt nicht automatisch zurueck (Stream weg) — User kann - // ARIAs letzte Antwort per Play-Button nochmal hoeren. - wakeWordService.resumeFromCall().catch(() => {}); - ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT); + // 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 | 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();