Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user