352 lines
14 KiB
Kotlin
352 lines
14 KiB
Kotlin
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
|
||
}
|
||
}
|