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 } }