Initial release of GPS2Audio
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user