Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -1,10 +1,18 @@
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
@@ -14,23 +22,35 @@ 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] (startet/stoppt den Vordergrundservice) und
* verwaltet den Status (pttActive, Gerätewahl, Fehler).
* 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.
* Dieser MVP setzt setPreferredDevice() und loggt Fehler.
* - 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) {
@@ -49,6 +69,9 @@ class LivePttManager private constructor(private val appContext: Context) {
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 ───────────────────────────────────────────────────
@@ -56,15 +79,19 @@ class LivePttManager private constructor(private val appContext: Context) {
private val _pttActive = MutableStateFlow(false)
val pttActive: StateFlow<Boolean> = _pttActive.asStateFlow()
/** Aktuell gespeicherte Routing-Einstellungen. */
/** 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 (aktualisiert beim Öffnen der Einstellungen). */
/** Verfügbare Eingabegeräte. */
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
/** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */
/** Verfügbare Ausgabegeräte. */
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
@@ -75,10 +102,27 @@ class LivePttManager private constructor(private val appContext: Context) {
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 {
// Gespeicherte Einstellungen laden
scope.launch {
store.settings.collect { settings ->
_routingSettings.value = settings
@@ -89,10 +133,7 @@ class LivePttManager private constructor(private val appContext: Context) {
// ─── PTT starten / stoppen ────────────────────────────────────────────────
/**
* Startet PTT:
* 1. Atmo pausieren
* 2. Foreground Service starten
* 3. Status setzen
* Startet PTT (Live und/oder Aufnahme) abhängig von den Einstellungen.
*/
fun startPtt() {
if (_pttActive.value) {
@@ -100,41 +141,69 @@ class LivePttManager private constructor(private val appContext: Context) {
return
}
// Begleitmusik pausieren
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()
val settings = _routingSettings.value
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)
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")
}
}
runCatching {
appContext.startForegroundService(intent)
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
_statusMessage.value = null
Log.i(TAG, "PTT gestartet")
}.onFailure { e ->
Log.e(TAG, "PTT konnte nicht gestartet werden", e)
_statusMessage.value = "PTT-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
} else {
_statusMessage.value = "PTT konnte nicht gestartet werden"
restoreAtmo()
}
}
/**
* Stoppt PTT:
* 1. Foreground Service stoppen
* 2. Atmo wiederherstellen
* 3. Status setzen
* 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
}
@@ -144,6 +213,26 @@ class LivePttManager private constructor(private val appContext: Context) {
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()
@@ -155,63 +244,105 @@ class LivePttManager private constructor(private val appContext: Context) {
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 ────────────────────────────────────────────────────
/** Lädt die verfügbaren Audiogeräte neu. */
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")
}
/** Speichert neue Routing-Einstellungen persistent. */
fun saveRoutingSettings(settings: AudioRoutingSettings) {
_routingSettings.value = settings
// 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(settings)
Log.i(TAG, "Routing-Einstellungen gespeichert: Input=${settings.selectedInputDeviceId}, Output=${settings.selectedOutputDeviceId}")
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 Routing-Einstellungen", e)
Log.e(TAG, "Fehler beim Speichern der PTT-Einstellungen", e)
}
}
}
/** Löscht die Statusmeldung. */
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 {
val mgr = BackgroundMusicManager.getInstance(appContext)
if (mgr.player.isPlaying) {
mgr.pauseMusic()
Log.d(TAG, "Atmo pausiert für PTT")
}
WaypointAudioInterruptionCoordinator.beginInterruption()
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht pausieren: ${e.message}")
Log.w(TAG, "Konnte Wegpunkt-Audio nicht pausieren (PTT-Start)", e)
}
}
private fun restoreAtmo() {
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
val settings = mgr.currentSettings
if (settings.enabled) {
mgr.afterWaypointAudio()
Log.d(TAG, "Atmo nach PTT wiederhergestellt")
}
WaypointAudioInterruptionCoordinator.endInterruption()
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht wiederherstellen: ${e.message}")
Log.w(TAG, "Konnte Wegpunkt-Audio nicht fortsetzen (PTT-Ende)", e)
}
atmoResume.notifyInterruptEnd(AtmoResumeManager.Source.PTT)
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
/** Ressourcen freigeben. Danach nicht mehr nutzbar. */
fun release() {
if (_pttActive.value) stopPtt()
scope.cancel()