Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bef59ba134 | |||
| dbebfd44ff | |||
| 4d0b9e0d78 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 802
|
versionCode 803
|
||||||
versionName "0.0.8.2"
|
versionName "0.0.8.3"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,71 @@ import android.media.AudioAttributes
|
|||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
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.Promise
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
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)
|
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
|
||||||
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
|
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
|
||||||
* - release() → Focus abgeben, andere Apps duerfen wieder
|
* - 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) {
|
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
override fun getName() = "AudioFocus"
|
override fun getName() = "AudioFocus"
|
||||||
|
|
||||||
|
companion object { private const val TAG = "AudioFocus" }
|
||||||
|
|
||||||
private var currentRequest: AudioFocusRequest? = null
|
private var currentRequest: AudioFocusRequest? = null
|
||||||
|
|
||||||
private fun audioManager(): AudioManager? =
|
private fun audioManager(): AudioManager? =
|
||||||
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? 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) {
|
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
|
||||||
val am = audioManager()
|
val am = audioManager()
|
||||||
if (am == null) {
|
if (am == null) {
|
||||||
@@ -41,13 +86,13 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
.build()
|
.build()
|
||||||
val req = AudioFocusRequest.Builder(durationHint)
|
val req = AudioFocusRequest.Builder(durationHint)
|
||||||
.setAudioAttributes(attrs)
|
.setAudioAttributes(attrs)
|
||||||
.setOnAudioFocusChangeListener { /* kein Callback noetig */ }
|
.setOnAudioFocusChangeListener(focusListener)
|
||||||
.build()
|
.build()
|
||||||
currentRequest = req
|
currentRequest = req
|
||||||
am.requestAudioFocus(req)
|
am.requestAudioFocus(req)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, durationHint)
|
am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||||
@@ -92,8 +137,24 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
currentRequest?.let { am.abandonAudioFocusRequest(it) }
|
currentRequest?.let { am.abandonAudioFocusRequest(it) }
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
am.abandonAudioFocus(null)
|
am.abandonAudioFocus(focusListener)
|
||||||
}
|
}
|
||||||
currentRequest = null
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,10 +137,12 @@ class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContex
|
|||||||
Log.w(TAG, "play() sofort failed: ${e.message}")
|
Log.w(TAG, "play() sofort failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Idle-Cutoff: wenn endRequested NICHT kam aber 30s nichts mehr
|
// Idle-Cutoff: wenn endRequested NICHT kam aber lange nichts mehr
|
||||||
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
|
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
|
||||||
|
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
|
||||||
|
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
|
||||||
var idleMs = 0L
|
var idleMs = 0L
|
||||||
val maxIdleMs = 30_000L
|
val maxIdleMs = 120_000L
|
||||||
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
|
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
|
||||||
// Stille rein damit AudioTrack nicht underrunt waehrend die
|
// Stille rein damit AudioTrack nicht underrunt waehrend die
|
||||||
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
|
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.0.8.2",
|
"version": "0.0.8.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
|
|||||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
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<number>(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 (
|
||||||
|
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={[CHAT_IMAGE_STYLE, { aspectRatio }]}
|
||||||
|
resizeMode="cover"
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
async function getAttachmentDir(): Promise<string> {
|
async function getAttachmentDir(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
|
const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
|
||||||
@@ -154,7 +193,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
const enabled = await AsyncStorage.getItem('aria_tts_enabled');
|
||||||
setTtsDeviceEnabled(enabled !== 'false'); // default true
|
setTtsDeviceEnabled(enabled !== 'false'); // default true
|
||||||
const muted = await AsyncStorage.getItem('aria_tts_muted');
|
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');
|
const voice = await AsyncStorage.getItem('aria_xtts_voice');
|
||||||
localXttsVoiceRef.current = voice || '';
|
localXttsVoiceRef.current = voice || '';
|
||||||
ttsSpeedRef.current = await loadTtsSpeed();
|
ttsSpeedRef.current = await loadTtsSpeed();
|
||||||
@@ -229,11 +270,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
setTtsMuted(prev => {
|
setTtsMuted(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
AsyncStorage.setItem('aria_tts_muted', String(next));
|
AsyncStorage.setItem('aria_tts_muted', String(next));
|
||||||
// Bei Muten sofort laufende Wiedergabe stoppen
|
// Ref synchron updaten — sonst kommen noch Chunks im selben Tick
|
||||||
if (next) audioService.stopPlayback();
|
// 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;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [ttsDeviceEnabled]);
|
||||||
|
|
||||||
// Chat-Verlauf aus AsyncStorage laden
|
// Chat-Verlauf aus AsyncStorage laden
|
||||||
const isInitialLoad = useRef(true);
|
const isInitialLoad = useRef(true);
|
||||||
@@ -925,11 +970,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
{item.attachments?.map((att, idx) => (
|
{item.attachments?.map((att, idx) => (
|
||||||
<View key={idx}>
|
<View key={idx}>
|
||||||
{att.type === 'image' && att.uri ? (
|
{att.type === 'image' && att.uri ? (
|
||||||
<TouchableOpacity onPress={() => setFullscreenImage(att.uri || null)} activeOpacity={0.8}>
|
<ChatImage
|
||||||
<Image
|
uri={att.uri}
|
||||||
source={{ uri: att.uri }}
|
onPress={() => setFullscreenImage(att.uri || null)}
|
||||||
style={styles.attachmentImage}
|
|
||||||
resizeMode="cover"
|
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setMessages(prev => prev.map(m =>
|
setMessages(prev => prev.map(m =>
|
||||||
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
|
m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
|
||||||
@@ -938,7 +981,6 @@ const ChatScreen: React.FC = () => {
|
|||||||
));
|
));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
|
||||||
) : att.type === 'image' && !att.uri ? (
|
) : att.type === 'image' && !att.uri ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.attachmentFile}
|
style={styles.attachmentFile}
|
||||||
@@ -1341,9 +1383,11 @@ const styles = StyleSheet.create({
|
|||||||
color: '#E0E0F0',
|
color: '#E0E0F0',
|
||||||
},
|
},
|
||||||
attachmentImage: {
|
attachmentImage: {
|
||||||
width: '100%',
|
// Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
|
||||||
minHeight: 200,
|
// Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
|
||||||
maxHeight: 400,
|
// Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
|
||||||
|
width: 260,
|
||||||
|
aspectRatio: 4 / 3,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
backgroundColor: '#0D0D1A',
|
backgroundColor: '#0D0D1A',
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const VAD_SPEECH_MIN_MS = 500; // ms Sprache bevor Aufnahme zaehlt — l
|
|||||||
// nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
|
// nicht zuverlaessig greift. Range -55..-15 dB. Speech-Schwelle wird auf
|
||||||
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein).
|
// override+10 dB gesetzt (Speech muss klar lauter als Stille sein).
|
||||||
export const VAD_SILENCE_DB_DEFAULT = -38; // wenn User Manuell-Modus waehlt
|
export const VAD_SILENCE_DB_DEFAULT = -38; // wenn User Manuell-Modus waehlt
|
||||||
export const VAD_SILENCE_DB_MIN = -55; // sehr empfindlich, fast jeder Pegel ist "Sprache"
|
export const VAD_SILENCE_DB_MIN = -85; // extrem empfindlich, praktisch alles gilt als Sprache
|
||||||
export const VAD_SILENCE_DB_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
|
export const VAD_SILENCE_DB_MAX = -15; // sehr unempfindlich, nur lautes Reden gilt
|
||||||
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override';
|
export const VAD_SILENCE_DB_OVERRIDE_KEY = 'aria_vad_silence_db_override';
|
||||||
|
|
||||||
@@ -610,7 +610,9 @@ class AudioService {
|
|||||||
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
|
/** Base64-kodiertes Audio in die Queue stellen und abspielen */
|
||||||
async playAudio(base64Data: string): Promise<void> {
|
async playAudio(base64Data: string): Promise<void> {
|
||||||
if (!base64Data) return;
|
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);
|
this.audioQueue.push(base64Data);
|
||||||
if (!this.isPlaying) {
|
if (!this.isPlaying) {
|
||||||
this._playNext();
|
this._playNext();
|
||||||
@@ -677,7 +679,9 @@ class AudioService {
|
|||||||
final?: boolean;
|
final?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
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) {
|
if (!silent && !PcmStreamPlayer) {
|
||||||
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
console.warn('[Audio] PcmStreamPlayer Native Module nicht verfuegbar');
|
||||||
return '';
|
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 */
|
/** Laufende Wiedergabe stoppen + Queue leeren */
|
||||||
stopPlayback(): void {
|
stopPlayback(): void {
|
||||||
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
// Foreground-Service auch stoppen — sonst bleibt die Notification haengen
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* PhoneCall-Service — pausiert die TTS-Wiedergabe wenn das Telefon klingelt
|
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
|
||||||
* oder ein Anruf laeuft. Native-Bindung an PhoneCallModule.kt.
|
|
||||||
*
|
*
|
||||||
* Bei "ringing" oder "offhook" wird audioService.haltAllPlayback() gerufen —
|
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
|
||||||
* ARIA verstummt sofort. Nach dem Auflegen passiert nichts automatisch
|
* Status: idle / ringing / offhook
|
||||||
* (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 —
|
* 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
|
||||||
* wenn nicht, failed start() leise und der Rest funktioniert wie bisher.
|
* 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 {
|
import {
|
||||||
@@ -33,61 +38,76 @@ type PhoneState = 'idle' | 'ringing' | 'offhook';
|
|||||||
class PhoneCallService {
|
class PhoneCallService {
|
||||||
private started: boolean = false;
|
private started: boolean = false;
|
||||||
private subscription: { remove: () => void } | null = null;
|
private subscription: { remove: () => void } | null = null;
|
||||||
|
private focusSubscription: { remove: () => void } | null = null;
|
||||||
private lastState: PhoneState = 'idle';
|
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> {
|
async start(): Promise<boolean> {
|
||||||
if (this.started || !PhoneCall) return false;
|
if (this.started || Platform.OS !== 'android') return false;
|
||||||
if (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 {
|
try {
|
||||||
const granted = await PermissionsAndroid.request(
|
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
|
||||||
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
|
this.focusSubscription = focusEmitter.addListener(
|
||||||
{
|
'AudioFocusChanged',
|
||||||
title: 'ARIA Cockpit — Anruf-Erkennung',
|
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
|
||||||
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.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
|
||||||
console.warn('[PhoneCall] READ_PHONE_STATE Permission abgelehnt');
|
} catch (err: any) {
|
||||||
return false;
|
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[PhoneCall] Permission-Anfrage gescheitert', err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
|
||||||
const ok = await PhoneCall.start();
|
if (PhoneCall) {
|
||||||
if (!ok) {
|
try {
|
||||||
console.warn('[PhoneCall] Native start() lieferte false (Permission?)');
|
const granted = await PermissionsAndroid.request(
|
||||||
return false;
|
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<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.started || !PhoneCall) return;
|
if (!this.started) return;
|
||||||
try {
|
try { this.subscription?.remove(); } catch {}
|
||||||
this.subscription?.remove();
|
try { this.focusSubscription?.remove(); } catch {}
|
||||||
this.subscription = null;
|
this.subscription = null;
|
||||||
await PhoneCall.stop();
|
this.focusSubscription = null;
|
||||||
} catch {}
|
if (PhoneCall) {
|
||||||
|
try { await PhoneCall.stop(); } catch {}
|
||||||
|
}
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.lastState = 'idle';
|
this.lastState = 'idle';
|
||||||
|
this.interruptedByFocus = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onStateChanged(state: PhoneState): void {
|
private _onStateChanged(state: PhoneState): void {
|
||||||
@@ -96,22 +116,75 @@ class PhoneCallService {
|
|||||||
console.log('[PhoneCall] State: %s → %s', prev, state);
|
console.log('[PhoneCall] State: %s → %s', prev, state);
|
||||||
this.lastState = state;
|
this.lastState = state;
|
||||||
if (state === 'ringing' || state === 'offhook') {
|
if (state === 'ringing' || state === 'offhook') {
|
||||||
audioService.haltAllPlayback(`Telefon-State: ${state}`);
|
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
} else if (state === 'idle' && prev !== 'idle') {
|
} else if (state === 'idle' && prev !== 'idle') {
|
||||||
// Auflegen: Wake-Word reaktivieren wenn vor dem Anruf aktiv war.
|
// Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
|
||||||
// TTS kommt nicht automatisch zurueck (Stream weg) — User kann
|
// Der Focus-Gain-Event triggert das Resume.
|
||||||
// ARIAs letzte Antwort per Play-Button nochmal hoeren.
|
if (!this.interruptedByFocus) {
|
||||||
wakeWordService.resumeFromCall().catch(() => {});
|
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
|
||||||
ToastAndroid.show('Anruf beendet — ARIA wieder aktiv', ToastAndroid.SHORT);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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();
|
const phoneCallService = new PhoneCallService();
|
||||||
|
|||||||
Reference in New Issue
Block a user