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() 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 = runCatching { repository.waypoints.first() }.getOrDefault(emptyList()) val currentInside = mutableSetOf() 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, 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)) } }