225 lines
8.7 KiB
Kotlin
225 lines
8.7 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.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
|
|
}
|
|
}
|