521 lines
20 KiB
Kotlin
521 lines
20 KiB
Kotlin
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))
|
||
}
|
||
}
|