Release GPS2Audio v2.5.44 POI category fix
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user