Initial release of GPS2Audio

This commit is contained in:
Marcel Mayer
2026-05-05 20:54:05 +02:00
commit b2aa1c167c
48 changed files with 11570 additions and 0 deletions
@@ -0,0 +1,458 @@
package de.waypointaudio.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import de.waypointaudio.MainActivity
import de.waypointaudio.R
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourMusicStore
import de.waypointaudio.data.Waypoint
import de.waypointaudio.repository.WaypointRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
// PTT-Manager (Singleton, lazy, null-safe)
/**
* Vordergrund-Dienst fuer GPS-Ueberwachung und Wegpunkt-Erkennung.
*
* Architektur:
* - Empfaengt Standort-Updates via FusedLocationProviderClient
* - Laedt Wegpunkte aus dem Repository
* - Berechnet fuer jeden Wegpunkt die Entfernung
* - Spielt die zugehoerige Audiodatei ab, wenn der Radius betreten wird
* - Verhindert wiederholte Wiedergabe bis der Benutzer die Zone verlassen hat
* - Beruecksichtigt Abspielregeln: EVERY_ENTRY, ONCE, LIMITED_COUNT + Zeitplan
*
* Begleitmusik-Sequenz (GPS-Trigger):
* 1. beforeWaypointAudio() wird vor der Wiedergabe aufgerufen (Pause/Fade/Duck)
* 2. AudioPlayer spielt den Wegpunkt-Ton ab
* 3. Nach natuerlichem Ende ODER Fehler ruft der onCompletion-/onError-Callback
* afterWaypointAudio() auf - niemals sofort nach dem play()-Aufruf.
* Callbacks laufen auf dem MediaPlayer-Thread und werden im Main-Dispatcher
* ausgefuehrt (ExoPlayer erfordert Main-Thread).
* 4. playCount wird nur bei natuerlicher Completion inkrementiert (nicht bei Fehler,
* nicht wenn Wiedergabe gar nicht starten konnte).
*/
class WaypointLocationService : Service() {
companion object {
const val ACTION_START = "de.waypointaudio.ACTION_START"
const val ACTION_STOP = "de.waypointaudio.ACTION_STOP"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "waypoint_service_channel"
private const val TAG = "WaypointLocationService"
// GPS-Aktualisierungsintervall
private const val LOCATION_INTERVAL_MS = 5_000L
private const val LOCATION_MIN_INTERVAL_MS = 2_000L
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var repository: WaypointRepository
private lateinit var musicStore: TourMusicStore
private val audioPlayer = AudioPlayer()
// Begleitmusik-Manager aus dem App-Prozess (null-safe wenn nicht initialisiert)
private val musicManager: BackgroundMusicManager?
get() = runCatching { BackgroundMusicManager.getInstance(applicationContext) }.getOrNull()
// PTT-Manager null-safe
private val pttManager: LivePttManager?
get() = runCatching { LivePttManager.getInstance(applicationContext) }.getOrNull()
// Letzter aufgeschobener Wegpunkt (während PTT aktiv war)
private var pendingPttWaypoint: Waypoint? = null
// Set von Wegpunkt-IDs, bei denen der Nutzer gerade im Radius ist
// (verhindert mehrfaches Ausloesen)
private val insideIds = mutableSetOf<String>()
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { location ->
checkWaypoints(location)
}
}
}
// ---------------------------------------------------------------------------
// Service-Lifecycle
// ---------------------------------------------------------------------------
override fun onCreate() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
repository = WaypointRepository(applicationContext)
musicStore = TourMusicStore(applicationContext)
createNotificationChannel()
Log.i(TAG, "Service erstellt")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startTracking()
ACTION_STOP -> stopTracking()
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopTracking()
serviceScope.cancel()
super.onDestroy()
Log.i(TAG, "Service zerstoert")
}
// ---------------------------------------------------------------------------
// Tracking starten / stoppen
// ---------------------------------------------------------------------------
private fun startTracking() {
val notification = buildNotification(getString(R.string.notification_text))
startForeground(NOTIFICATION_ID, notification)
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, LOCATION_INTERVAL_MS)
.setMinUpdateIntervalMillis(LOCATION_MIN_INTERVAL_MS)
.build()
runCatching {
fusedLocationClient.requestLocationUpdates(
request,
locationCallback,
Looper.getMainLooper()
)
}.onFailure { e ->
Log.e(TAG, "Standort-Updates konnten nicht gestartet werden", e)
}
Log.i(TAG, "GPS-Tracking gestartet")
}
private fun stopTracking() {
fusedLocationClient.removeLocationUpdates(locationCallback)
audioPlayer.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.i(TAG, "GPS-Tracking gestoppt")
}
// ---------------------------------------------------------------------------
// Wegpunkt-Pruefung
// ---------------------------------------------------------------------------
private fun checkWaypoints(location: Location) {
serviceScope.launch {
val waypoints: List<Waypoint> = runCatching {
repository.waypoints.first()
}.getOrDefault(emptyList())
val currentInside = mutableSetOf<String>()
for (wp in waypoints) {
if (!wp.isActive) continue
val results = FloatArray(1)
Location.distanceBetween(
location.latitude, location.longitude,
wp.latitude, wp.longitude,
results
)
val distanceM = results[0]
if (distanceM <= wp.radiusMeters) {
currentInside.add(wp.id)
// Nur ausloesen, wenn zuvor ausserhalb (Eintrittsereignis)
if (!insideIds.contains(wp.id)) {
Log.i(TAG, "Wegpunkt betreten: ${wp.name} (Entfernung: ${distanceM.toInt()}m)")
onWaypointEntered(wp)
}
}
}
// IDs aktualisieren (verlassene Zonen entfernen)
insideIds.retainAll(currentInside)
insideIds.addAll(currentInside)
}
}
private fun onWaypointEntered(waypoint: Waypoint) {
// Benachrichtigung aktualisieren
val msg = getString(R.string.notification_waypoint_reached, waypoint.name)
updateNotification(msg)
// Ton nur abspielen wenn Audiodatei vorhanden und Abspielregeln erfuellt
if (waypoint.soundUri.isBlank()) {
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine Audiodatei")
return
}
if (!isPlaybackAllowed(waypoint)) {
Log.i(TAG, "Ton nicht abgespielt - Abspielregel oder Zeitplan verhindert Wiedergabe: ${waypoint.name}")
return
}
// PTT-Sperre: Wenn PTT aktiv, Wegpunkt aufschoeben und spaeter abspielen
val ptt = pttManager
if (ptt != null && ptt.pttActive.value) {
Log.i(TAG, "PTT aktiv - Wegpunkt '${waypoint.name}' wird aufgeschoben")
pendingPttWaypoint = waypoint
// Sobald PTT inaktiv wird: verschobenen Wegpunkt abspielen
serviceScope.launch(Dispatchers.Main) {
ptt.pttActive.collect { active ->
if (!active) {
val pending = pendingPttWaypoint
pendingPttWaypoint = null
if (pending != null) {
Log.i(TAG, "PTT beendet - verschobenen Wegpunkt '${pending.name}' wird abgespielt")
playWaypointAudio(pending)
}
// Collector einmalig beenden (kein further collecting noetig)
return@collect
}
}
}
return
}
playWaypointAudio(waypoint)
}
/**
* Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz).
* Kann direkt oder nach PTT-Ende aufgerufen werden.
*/
private fun playWaypointAudio(waypoint: Waypoint) {
// Begleitmusik-Sequenz:
// 1. beforeWaypointAudio() - Begleitmusik pausieren/ducken/faden
// 2. audioPlayer.play() - Wegpunkt-Ton starten
// 3a. onCompletion - nach naturalem Ende: afterWaypointAudio() + playCount++
// 3b. onError - bei Fehler: afterWaypointAudio() (kein playCount++)
//
// Alles laeuft im Main-Dispatcher, da ExoPlayer (Begleitmusik) Main erfordert.
// Die Callbacks von AudioPlayer kommen auf dem MediaPlayer-Thread und werden
// per serviceScope.launch(Dispatchers.Main) weitergeleitet.
serviceScope.launch(Dispatchers.Main) {
val mgr = musicManager
// Tour-Einstellungen laden
val settings: TourAudioSettings? = if (mgr != null) {
runCatching {
val tourName = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
musicStore.settingsForTour(tourName).first()
}.getOrNull()
} else null
// 1. Begleitmusik vor Wegpunkt-Audio anpassen
if (mgr != null && settings != null && settings.enabled) {
runCatching {
mgr.currentSettings = settings
mgr.beforeWaypointAudio()
}.onFailure { e ->
Log.w(TAG, "Begleitmusik before-Fehler: ${e.message}")
}
}
// 2. Wegpunkt-Ton abspielen
// onCompletion und onError werden auf dem MediaPlayer-Thread aufgerufen;
// wir leiten sie zurueck in den Main-Dispatcher fuer ExoPlayer-Zugriffe.
audioPlayer.play(
context = applicationContext,
soundUri = waypoint.soundUri,
onCompletion = {
// Natuerliches Ende: Begleitmusik wiederherstellen + Zaehler erhoehen
serviceScope.launch(Dispatchers.Main) {
Log.d(TAG, "Wegpunkt-Ton beendet (natural completion): ${waypoint.name}")
restoreBackgroundMusic(mgr, settings)
}
// playCount auf IO-Thread inkrementieren (Repository-Zugriff)
serviceScope.launch(Dispatchers.IO) {
runCatching {
repository.incrementPlayCount(waypoint.id)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e)
}
}
},
onError = { e ->
// Fehler: Begleitmusik trotzdem wiederherstellen; kein playCount++
serviceScope.launch(Dispatchers.Main) {
Log.w(TAG, "Wegpunkt-Ton Fehler (${waypoint.name}): ${e?.message}")
restoreBackgroundMusic(mgr, settings)
}
}
)
}
}
/**
* Stellt die Begleitmusik nach dem Wegpunkt-Ton wieder her.
* Muss auf dem Main-Thread aufgerufen werden (ExoPlayer-Anforderung).
*/
private fun restoreBackgroundMusic(
mgr: BackgroundMusicManager?,
settings: TourAudioSettings?
) {
if (mgr == null || settings == null || !settings.enabled) return
runCatching {
mgr.afterWaypointAudio()
}.onFailure { e ->
Log.w(TAG, "Begleitmusik after-Fehler: ${e.message}")
}
}
// ---------------------------------------------------------------------------
// Abspielregel-Pruefung
// ---------------------------------------------------------------------------
/**
* Prueft ob die Abspielregeln und der Zeitplan die Wiedergabe erlauben.
*
* Logik:
* 1. Modus-Pruefung:
* - EVERY_ENTRY: immer erlaubt (solange Zeitplan passt)
* - ONCE: nur wenn playCount == 0
* - LIMITED_COUNT: nur wenn playCount < maxPlayCount (null/0 kein Abspielen)
* 2. Zeitplan (nur wenn scheduleEnabled):
* - scheduleStartMillis: aktuelle Zeit muss >= Start sein
* - scheduleEndMillis: aktuelle Zeit muss <= Ende sein
* - allowedStartMinutes / allowedEndMinutes: Tagesminute muss im Fenster liegen
* (Mitternachts-uebergreifende Fenster werden unterstuetzt)
*/
private fun isPlaybackAllowed(waypoint: Waypoint): Boolean {
val nowMillis = System.currentTimeMillis()
// --- Modus-Pruefung ---
val modeAllowed = when (waypoint.playbackMode) {
PlaybackMode.EVERY_ENTRY -> true
PlaybackMode.ONCE -> waypoint.playCount == 0
PlaybackMode.LIMITED_COUNT -> {
val max = waypoint.maxPlayCount
if (max == null || max <= 0) {
// Keine gueltige Obergrenze kein Abspielen
false
} else {
waypoint.playCount < max
}
}
}
if (!modeAllowed) return false
// --- Zeitplan-Pruefung (nur wenn aktiv) ---
if (!waypoint.scheduleEnabled) return true
// Datum/Zeit-Fenster: wenn gesetzt, aktuelle Zeit muss im Bereich liegen
waypoint.scheduleStartMillis?.let { start ->
if (nowMillis < start) {
Log.d(TAG, "Zeitplan: Startzeit noch nicht erreicht fuer '${waypoint.name}'")
return false
}
}
waypoint.scheduleEndMillis?.let { end ->
if (nowMillis > end) {
Log.d(TAG, "Zeitplan: Endzeitpunkt ueberschritten fuer '${waypoint.name}'")
return false
}
}
// Taegliches Zeitfenster (Tagesminuten)
val startMin = waypoint.allowedStartMinutes
val endMin = waypoint.allowedEndMinutes
if (startMin != null || endMin != null) {
val cal = Calendar.getInstance()
val currentMinuteOfDay = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
if (startMin != null && endMin != null) {
val inWindow = if (startMin <= endMin) {
// Normales Fenster, z. B. 08:00-20:00
currentMinuteOfDay in startMin..endMin
} else {
// Mitternachts-uebergreifend, z. B. 22:00-06:00
currentMinuteOfDay >= startMin || currentMinuteOfDay <= endMin
}
if (!inWindow) {
Log.d(TAG, "Zeitplan: Ausserhalb des Tages-Zeitfensters fuer '${waypoint.name}'")
return false
}
} else if (startMin != null) {
if (currentMinuteOfDay < startMin) {
Log.d(TAG, "Zeitplan: Vor dem Tages-Start fuer '${waypoint.name}'")
return false
}
} else if (endMin != null) {
if (currentMinuteOfDay > endMin) {
Log.d(TAG, "Zeitplan: Nach dem Tages-Ende fuer '${waypoint.name}'")
return false
}
}
}
return true
}
// ---------------------------------------------------------------------------
// Benachrichtigungen
// ---------------------------------------------------------------------------
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW // lautlos, keine Vibration
).apply {
description = getString(R.string.notification_channel_description)
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
private fun buildNotification(contentText: String): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIFICATION_ID, buildNotification(text))
}
}