Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt
T
2026-05-30 05:42:44 +00:00

352 lines
14 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <filesDir>/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<Boolean> = _pttActive.asStateFlow()
/** true wenn aktuell eine Aufnahme läuft. */
private val _recordingActive = MutableStateFlow(false)
val recordingActive: StateFlow<Boolean> = _recordingActive.asStateFlow()
/** Aktuell gespeicherte Routing-/PTT-Einstellungen. */
private val _routingSettings = MutableStateFlow(AudioRoutingSettings())
val routingSettings: StateFlow<AudioRoutingSettings> = _routingSettings.asStateFlow()
/** Verfügbare Eingabegeräte. */
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
/** Verfügbare Ausgabegeräte. */
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
/**
* Status-/Fehlermeldung für die UI.
* null = kein Fehler / kein Hinweis
*/
private val _statusMessage = MutableStateFlow<String?>(null)
val statusMessage: StateFlow<String?> = _statusMessage.asStateFlow()
/**
* Reaktiver Flow der gespeicherten Aufnahmen. Wird von der UI für Bibliothek
* und Post-Aufnahme-Dialog beobachtet.
*/
val recordings: Flow<List<PttRecording>> = recordingStore.recordings
/**
* Zuletzt fertig gestellte Aufnahme (für Post-Aufnahme-Dialog).
* Wird von der UI mit [clearLastRecording] zurückgesetzt.
*/
private val _lastRecording = MutableStateFlow<PttRecording?>(null)
val lastRecording: StateFlow<PttRecording?> = _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<Double?, Double?> {
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
}
}