package de.waypointaudio.service import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.util.Log import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationServices import de.waypointaudio.data.AudioRoutingSettings import de.waypointaudio.data.AudioRoutingStore import de.waypointaudio.data.PttRecording import de.waypointaudio.data.PttRecordingFormat import de.waypointaudio.data.PttRecordingStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withTimeoutOrNull /** * App-weiter Singleton-Manager für Live / PTT. * * Steuert den [LivePttService] (Live-Ausgabe) und/oder den [PttRecorder] (Aufnahme), * je nach Nutzer-Einstellungen ([AudioRoutingSettings.liveOutputEnabled], * [AudioRoutingSettings.recordingEnabled]). * * Audio-Priorität: * - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]). * - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe. * - Beim PTT-Ende: Begleitmusik wird wiederhergestellt (über [BackgroundMusicManager.afterWaypointAudio]). * * Aufnahme: * - MediaRecorder läuft parallel zum Live-Loop (AudioRecord). Auf einigen Geräten * kann eines von beiden versagen; in diesem Fall wird der Vorgang fortgesetzt * und die UI über [statusMessage] informiert. * - Aufnahmen werden in /recordings/ abgelegt und in [PttRecordingStore] * indiziert. Wegpunkt-Zuweisung erfolgt über das Standard-soundUri-Feld. * * Bekannte Einschränkungen: * - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt. * - Bluetooth SCO: Erfordert ggf. AudioManager.startBluetoothSco() für volles Routing. * - Echo/Feedback: Bei Nutzung des eingebauten Lautsprechers ohne Headset zu erwarten. * - Live + Aufnahme gleichzeitig: Auf einigen Geräten konkurrieren AudioRecord * (Live) und MediaRecorder (Aufnahme) um das Mikrofon. Bei Fehler wird die * Aufnahme bevorzugt und die Live-Ausgabe übersprungen (oder umgekehrt). */ class LivePttManager private constructor(private val appContext: Context) { companion object { private const val TAG = "LivePttManager" @Volatile private var instance: LivePttManager? = null fun getInstance(context: Context): LivePttManager { return instance ?: synchronized(this) { instance ?: LivePttManager(context.applicationContext).also { instance = it } } } } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val store = AudioRoutingStore(appContext) private val recordingStore = PttRecordingStore(appContext) private val recorder = PttRecorder(appContext) private val fusedLocation = LocationServices.getFusedLocationProviderClient(appContext) // ─── Öffentliche States ─────────────────────────────────────────────────── /** true wenn PTT gerade aktiv ist. */ private val _pttActive = MutableStateFlow(false) val pttActive: StateFlow = _pttActive.asStateFlow() /** true wenn aktuell eine Aufnahme läuft. */ private val _recordingActive = MutableStateFlow(false) val recordingActive: StateFlow = _recordingActive.asStateFlow() /** Aktuell gespeicherte Routing-/PTT-Einstellungen. */ private val _routingSettings = MutableStateFlow(AudioRoutingSettings()) val routingSettings: StateFlow = _routingSettings.asStateFlow() /** Verfügbare Eingabegeräte. */ private val _inputDevices = MutableStateFlow>(emptyList()) val inputDevices: StateFlow> = _inputDevices.asStateFlow() /** Verfügbare Ausgabegeräte. */ private val _outputDevices = MutableStateFlow>(emptyList()) val outputDevices: StateFlow> = _outputDevices.asStateFlow() /** * Status-/Fehlermeldung für die UI. * null = kein Fehler / kein Hinweis */ private val _statusMessage = MutableStateFlow(null) val statusMessage: StateFlow = _statusMessage.asStateFlow() /** * Reaktiver Flow der gespeicherten Aufnahmen. Wird von der UI für Bibliothek * und Post-Aufnahme-Dialog beobachtet. */ val recordings: Flow> = recordingStore.recordings /** * Zuletzt fertig gestellte Aufnahme (für Post-Aufnahme-Dialog). * Wird von der UI mit [clearLastRecording] zurückgesetzt. */ private val _lastRecording = MutableStateFlow(null) val lastRecording: StateFlow = _lastRecording.asStateFlow() /** * Zentraler Atmo-Resume-Manager. */ private val atmoResume = AtmoResumeManager.getInstance(appContext) // ─── Initialisierung ────────────────────────────────────────────────────── init { scope.launch { store.settings.collect { settings -> _routingSettings.value = settings } } } // ─── PTT starten / stoppen ──────────────────────────────────────────────── /** * Startet PTT (Live und/oder Aufnahme) abhängig von den Einstellungen. */ fun startPtt() { if (_pttActive.value) { Log.d(TAG, "PTT bereits aktiv – kein Neustart") return } val settings = _routingSettings.value // Sicherheit: Wenn der Nutzer beides deaktiviert hat, Live-Modus erzwingen. val live = settings.liveOutputEnabled || !settings.recordingEnabled val record = settings.recordingEnabled // Begleitmusik & Wegpunkt-Audio pausieren pauseAtmo() var liveStarted = false var recordStarted = false // Aufnahme zuerst starten (höhere Priorität für saubere Datei). Wenn beides // aktiv ist, beansprucht MediaRecorder den MIC-Kanal; der Live-AudioRecord // läuft auf VOICE_COMMUNICATION und teilt sich auf den meisten Geräten den // Eingang ohne Konflikt. if (record) { val file = recorder.start(settings.recordingFormat) if (file != null) { recordStarted = true _recordingActive.value = true Log.i(TAG, "PTT-Aufnahme gestartet: ${file.absolutePath}") } else { _statusMessage.value = "Aufnahme konnte nicht gestartet werden" Log.w(TAG, "PTT-Aufnahme konnte nicht gestartet werden") } } if (live) { val intent = Intent(appContext, LivePttService::class.java).apply { action = LivePttService.ACTION_START_PTT settings.selectedInputDeviceId?.let { putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it) } settings.selectedOutputDeviceId?.let { putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it) } } runCatching { appContext.startForegroundService(intent) liveStarted = true Log.i(TAG, "PTT-Live gestartet") }.onFailure { e -> Log.e(TAG, "PTT-Live konnte nicht gestartet werden", e) _statusMessage.value = "PTT-Live-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}" } } if (liveStarted || recordStarted) { _pttActive.value = true } else { _statusMessage.value = "PTT konnte nicht gestartet werden" restoreAtmo() } } /** * Stoppt PTT. Aufnahme wird finalisiert und in der Bibliothek gespeichert; * die fertige Aufnahme wird in [lastRecording] gepostet. */ fun stopPtt() { if (!_pttActive.value) return // Live-Service stoppen (falls aktiv) val intent = Intent(appContext, LivePttService::class.java).apply { action = LivePttService.ACTION_STOP_PTT } runCatching { appContext.startService(intent) }.onFailure { e -> Log.w(TAG, "Fehler beim Stoppen des PTT-Service", e) } // Aufnahme finalisieren – Standort optional ermitteln if (_recordingActive.value) { scope.launch { val (lat, lon) = fetchLastLocationOrNull() val meta = recorder.stop(latitude = lat, longitude = lon) _recordingActive.value = false if (meta != null) { runCatching { recordingStore.add(meta) }.onFailure { e -> Log.e(TAG, "Aufnahme-Metadaten konnten nicht gespeichert werden", e) } _lastRecording.value = meta Log.i(TAG, "PTT-Aufnahme gespeichert: ${meta.filePath} (${meta.durationMs} ms)") } else { Log.w(TAG, "PTT-Aufnahme lieferte keine Metadaten zurück (Fehler oder zu kurz)") } } } _pttActive.value = false _statusMessage.value = null restoreAtmo() Log.i(TAG, "PTT gestoppt") } /** Schaltet PTT ein/aus. */ fun togglePtt() { if (_pttActive.value) stopPtt() else startPtt() } /** Verwirft die zuletzt fertig gestellte Aufnahme aus der UI-Anzeige (Dialog geschlossen). */ fun clearLastRecording() { _lastRecording.value = null } // ─── Aufnahme-Bibliothek ────────────────────────────────────────────────── /** Löscht eine Aufnahme (Datei + Metadaten). */ fun deleteRecording(recording: PttRecording) { scope.launch { runCatching { val f = java.io.File(recording.filePath) if (f.exists()) f.delete() recordingStore.delete(recording.id) Log.i(TAG, "Aufnahme gelöscht: ${recording.displayName}") }.onFailure { e -> Log.e(TAG, "Fehler beim Löschen der Aufnahme", e) } } } /** Benennt eine Aufnahme um (nur Anzeigename). */ fun renameRecording(recording: PttRecording, newName: String) { scope.launch { runCatching { recordingStore.rename(recording.id, newName.trim()) } .onFailure { e -> Log.e(TAG, "Fehler beim Umbenennen", e) } } } // ─── Geräteverwaltung ──────────────────────────────────────────────────── fun refreshDevices() { _inputDevices.value = AudioDeviceManager.getInputDevices(appContext) _outputDevices.value = AudioDeviceManager.getOutputDevices(appContext) Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe") } fun saveRoutingSettings(settings: AudioRoutingSettings) { // Validierung: mindestens eine Option (Live oder Aufnahme) muss aktiv sein. val safe = if (!settings.liveOutputEnabled && !settings.recordingEnabled) { settings.copy(liveOutputEnabled = true) } else settings _routingSettings.value = safe scope.launch { runCatching { store.save(safe) Log.i(TAG, "PTT-Einstellungen gespeichert: live=${safe.liveOutputEnabled}, " + "record=${safe.recordingEnabled}, format=${safe.recordingFormat}") }.onFailure { e -> Log.e(TAG, "Fehler beim Speichern der PTT-Einstellungen", e) } } } fun clearStatus() { _statusMessage.value = null } // ─── Standort-Abfrage für Aufnahme-Metadaten ───────────────────────────── @SuppressLint("MissingPermission") private suspend fun fetchLastLocationOrNull(): Pair { val fine = ContextCompat.checkSelfPermission( appContext, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED val coarse = ContextCompat.checkSelfPermission( appContext, Manifest.permission.ACCESS_COARSE_LOCATION ) == PackageManager.PERMISSION_GRANTED if (!fine && !coarse) return null to null return withTimeoutOrNull(1500L) { runCatching { val loc = fusedLocation.lastLocation.await() if (loc != null) loc.latitude to loc.longitude else null to null }.getOrElse { null to null } } ?: (null to null) } // ─── Atmo-Integration ──────────────────────────────────────────────────── private fun pauseAtmo() { atmoResume.notifyInterruptStart(AtmoResumeManager.Source.PTT) runCatching { WaypointAudioInterruptionCoordinator.beginInterruption() }.onFailure { e -> Log.w(TAG, "Konnte Wegpunkt-Audio nicht pausieren (PTT-Start)", e) } } private fun restoreAtmo() { runCatching { WaypointAudioInterruptionCoordinator.endInterruption() }.onFailure { e -> Log.w(TAG, "Konnte Wegpunkt-Audio nicht fortsetzen (PTT-Ende)", e) } atmoResume.notifyInterruptEnd(AtmoResumeManager.Source.PTT) } // ─── Lifecycle ──────────────────────────────────────────────────────────── fun release() { if (_pttActive.value) stopPtt() scope.cancel() instance = null } }