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

521 lines
20 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.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.data.activeClips
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()
WaypointAudioInterruptionCoordinator.unregister(
WaypointAudioInterruptionCoordinator.Source.GPS
)
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)
// v2.5.12 — Effektive Clipliste prüfen. activeClips() berücksichtigt
// sowohl das neue audioClips-Modell als auch Legacy-soundUri.
if (waypoint.activeClips().isEmpty()) {
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine aktiven Audioclips")
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.
*
* v2.5.12 — Multi-Clip:
* - Es werden ALLE aktiven Clips des Wegpunkts der Reihe nach abgespielt.
* - Inaktive Clips werden übersprungen (siehe [Waypoint.activeClips]).
* - Begleitmusik wird einmalig vor dem ersten Clip pausiert/geduckt
* und einmalig nach dem letzten Clip wiederhergestellt — nicht zwischen
* den Clips, damit die Sequenz nahtlos wirkt.
* - playCount wird genau einmal pro Eintrittsereignis erhöht, nachdem
* der letzte Clip natürlich endete (keine Erhöhung bei Fehler).
* - Bei einem Fehler eines Clips wird abgebrochen, Begleitmusik wieder
* hergestellt; playCount bleibt unverändert.
*/
private fun playWaypointAudio(waypoint: Waypoint) {
serviceScope.launch(Dispatchers.Main) {
val mgr = musicManager
val clipUris = waypoint.activeClips().map { it.uri }
if (clipUris.isEmpty()) {
Log.d(TAG, "playWaypointAudio: keine aktiven Clips für '${waypoint.name}'")
return@launch
}
// 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 einmalig vor erstem Clip 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. Beim PTT-Coordinator registrieren über die gesamte
// Multi-Clip-Sequenz hinweg. Pause/Resume betreffen immer
// den aktuell aktiven MediaPlayer im AudioPlayer-Wrapper.
WaypointAudioInterruptionCoordinator.register(
source = WaypointAudioInterruptionCoordinator.Source.GPS,
isCurrentlyPlaying = { audioPlayer.isPlaying },
pause = { audioPlayer.pause() },
resume = { audioPlayer.resume() }
)
// 3. Clips sequenziell abspielen.
playClipSequence(
waypoint = waypoint,
clipUris = clipUris,
index = 0,
mgr = mgr,
settings = settings
)
}
}
/**
* v2.5.12 — Spielt den Clip mit Index [index] und ruft sich selbst nach
* natürlichem Ende für den nächsten Clip auf. Bei Fehler oder am Ende
* der Liste wird die Begleitmusik wiederhergestellt; der Coordinator
* abgemeldet; und bei natürlichem Ende der gesamten Sequenz wird
* playCount inkrementiert.
*/
private fun playClipSequence(
waypoint: Waypoint,
clipUris: List<String>,
index: Int,
mgr: BackgroundMusicManager?,
settings: TourAudioSettings?
) {
if (index !in clipUris.indices) {
// Sequenz erfolgreich durchlaufen
finishSequence(waypoint, mgr, settings, incrementPlayCount = true)
return
}
val uri = clipUris[index]
audioPlayer.play(
context = applicationContext,
soundUri = uri,
onCompletion = {
serviceScope.launch(Dispatchers.Main) {
Log.d(TAG, "Clip ${index + 1}/${clipUris.size} beendet (${waypoint.name})")
playClipSequence(waypoint, clipUris, index + 1, mgr, settings)
}
},
onError = { e ->
Log.w(TAG, "Clip ${index + 1}/${clipUris.size} Fehler (${waypoint.name}): ${e?.message}")
// Bei Fehler abbrechen, Begleitmusik wiederherstellen, KEIN playCount++
serviceScope.launch(Dispatchers.Main) {
finishSequence(waypoint, mgr, settings, incrementPlayCount = false)
}
}
)
}
private fun finishSequence(
waypoint: Waypoint,
mgr: BackgroundMusicManager?,
settings: TourAudioSettings?,
incrementPlayCount: Boolean
) {
WaypointAudioInterruptionCoordinator.unregister(
WaypointAudioInterruptionCoordinator.Source.GPS
)
restoreBackgroundMusic(mgr, settings)
if (incrementPlayCount) {
serviceScope.launch(Dispatchers.IO) {
runCatching {
repository.incrementPlayCount(waypoint.id)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e)
}
}
}
}
/**
* 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))
}
}