Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -0,0 +1,224 @@
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.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import de.waypointaudio.MainActivity
import de.waypointaudio.R
/**
* v2.5.10 — Foreground-Service mit Typ `mediaPlayback`, der die manuelle
* Wiedergabe (Tour-/Track-Play aus dem Waypoint-Listenmodus heraus) am
* Leben hält.
*
* **Problem in v2.5.8 und davor**
* ===============================
* `ManualAudioPlayer` benutzt einen einfachen [android.media.MediaPlayer],
* aber ohne dedizierten Foreground-Service. Sobald der Bildschirm sich
* abschaltet (Doze, Light-Doze, App-Standby), wird die App nach ca. 30
* Minuten in den Cached-Zustand verschoben, der CPU-Scheduler bremst und
* MediaPlayer fällt schließlich aus.
*
* **Lösung in v2.5.10**
* ======================
* Sobald die manuelle Wiedergabe startet (`ManualAudioPlayer.play(...)` oder
* `playSingle(...)`), wird dieser Service über [start] gestartet. Er:
*
* - ruft `startForeground(...)` mit Typ `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK`
* auf — das ist die Voraussetzung für stabile Hintergrund-Wiedergabe
* auf Android 12+/14;
* - hält einen `PowerManager.PARTIAL_WAKE_LOCK`, damit Doze den CPU-Takt
* nicht zurückfährt;
* - zeigt eine niederpriore Notification mit Titel/Artist, die der
* bestehenden Atmo-Notification ähnelt.
*
* **Wichtig: keine Player-Ownership im Service**
* ===============================================
* Der eigentliche [android.media.MediaPlayer] bleibt — wie bisher — im
* [ManualAudioPlayer] der ViewModel-Schicht. Der Service ist nur der
* "Lebenshalter" und tritt mit dem Player NICHT in Interaktion. Das hält
* die Architektur einfach und vermeidet Regressionen in PTT-Interruption-
* und Atmo-Koexistenz-Logik, die alle gegen [ManualAudioPlayer] geschrieben
* wurden.
*
* Der Service stoppt sich selbst auf [ACTION_STOP] oder beim Service-
* destroy. WakeLock wird zwingend in [onDestroy] freigegeben.
*/
class ManualPlaybackService : Service() {
companion object {
const val ACTION_START = "de.waypointaudio.manual.ACTION_START"
const val ACTION_STOP = "de.waypointaudio.manual.ACTION_STOP"
const val ACTION_UPDATE_META = "de.waypointaudio.manual.ACTION_UPDATE_META"
const val EXTRA_TITLE = "manual_title"
const val EXTRA_TOUR = "manual_tour"
private const val TAG = "ManualPlaybackService"
private const val NOTIFICATION_ID = 1201
const val CHANNEL_ID = "manual_playback_channel"
/**
* Startet den Service, falls noch nicht aktiv, und aktualisiert
* Titel/Tour-Anzeige in der Notification.
*/
fun start(context: Context, title: String?, tour: String?) {
val intent = Intent(context, ManualPlaybackService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TITLE, title.orEmpty())
putExtra(EXTRA_TOUR, tour.orEmpty())
}
// startForegroundService MUSS auf Android O+ verwendet werden.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
/** Aktualisiert nur die Notification-Texte, ohne den Service neu zu starten. */
fun updateMeta(context: Context, title: String?, tour: String?) {
val intent = Intent(context, ManualPlaybackService::class.java).apply {
action = ACTION_UPDATE_META
putExtra(EXTRA_TITLE, title.orEmpty())
putExtra(EXTRA_TOUR, tour.orEmpty())
}
try { context.startService(intent) } catch (e: Exception) {
Log.w(TAG, "updateMeta failed: ${e.message}")
}
}
fun stop(context: Context) {
val intent = Intent(context, ManualPlaybackService::class.java).apply {
action = ACTION_STOP
}
try { context.startService(intent) } catch (e: Exception) {
Log.w(TAG, "stop dispatch failed: ${e.message}")
}
}
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = context.getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Manuelle Wiedergabe",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Hält Tour-/Trackwiedergabe im Hintergrund stabil"
setShowBadge(false)
}
)
}
}
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var currentTitle: String = ""
private var currentTour: String = ""
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ensureChannel(this)
// Sofortige Foreground-Notification: System gewährt nur ~5s Frist
// nach startForegroundService(), bevor es eine ANR-artige Sanktion
// gibt — wir nutzen daher schon hier eine valide Minimal-Notification.
startForegroundCompat(buildNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
Log.d(TAG, "ACTION_STOP")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
ACTION_START, ACTION_UPDATE_META -> {
currentTitle = intent.getStringExtra(EXTRA_TITLE).orEmpty()
currentTour = intent.getStringExtra(EXTRA_TOUR).orEmpty()
acquireWakeLockIfNeeded()
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIFICATION_ID, buildNotification())
}
else -> {
// Re-Delivery / null-Intent: einfach am Leben bleiben.
acquireWakeLockIfNeeded()
}
}
return START_STICKY
}
override fun onDestroy() {
releaseWakeLock()
super.onDestroy()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun buildNotification(): Notification {
val pi = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val title = currentTitle.ifBlank { getString(R.string.app_name) }
val text = if (currentTour.isNotBlank()) "Tour: $currentTour" else "Wiedergabe läuft"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_splash_logo)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setContentIntent(pi)
.build()
}
private fun acquireWakeLockIfNeeded() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"GPS2Audio:ManualPlayback"
).also { lock ->
lock.setReferenceCounted(false)
// 4h Hard-Cap als Sicherheitsnetz, falls Stopp-Pfad jemals
// ausgelassen wird. Im Regelfall wird der Lock durch
// onDestroy/Release lange vorher freigegeben.
lock.acquire(4 * 60 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
wakeLock = null
}
}