From baf2bea3cf43e7f3f1837f67f1111fd7bc120e0c Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 27 Apr 2026 04:49:04 -0400 Subject: [PATCH] fix: add audio focus management and fix service stop lifecycle The app never requested audio focus, so it played over other apps and ignored phone calls, music players, etc. Now requests AUDIOFOCUS_GAIN before playback and handles LOSS (stop), LOSS_TRANSIENT (mute), LOSS_TRANSIENT_CAN_DUCK (lower volume to 20%), and GAIN (restore). Also fixes several service lifecycle issues: - Add stopForeground(STOP_FOREGROUND_REMOVE) in cleanupResources so the notification is dismissed when playback ends - Add onTaskRemoved override to stop playback when the app is swiped from recents (unless stayConnected is enabled) - Fix wake lock leak by calling releaseLocks() before acquireLocks() to prevent accumulating unreleased locks across pause/resume cycles - Add AudioEngine.setVolume() for ducking support Made-with: Cursor --- .../cottongin/radio247/audio/AudioEngine.kt | 4 + .../radio247/service/RadioPlaybackService.kt | 78 +++++++++++++++++++ .../radio247/audio/AudioEngineVolumeTest.kt | 27 +++++++ .../service/PlaybackStateMachineTest.kt | 25 ++++++ 4 files changed, 134 insertions(+) create mode 100644 app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt index f9b675e..87d81ef 100644 --- a/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt +++ b/app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt @@ -84,6 +84,10 @@ class AudioEngine( pendingSkips.incrementAndGet() } + fun setVolume(gain: Float) { + currentAudioTrack?.setVolume(gain.coerceIn(0f, 1f)) + } + private fun runPipeline() { Log.i(TAG, "runPipeline() connecting to $url") val connection = StreamConnection(url) diff --git a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt index 349cb3e..5954ce9 100644 --- a/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +++ b/app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt @@ -5,6 +5,9 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities @@ -114,6 +117,34 @@ class RadioPlaybackService : MediaLibraryService() { private var playJob: Job? = null + private var audioManager: AudioManager? = null + private var focusRequest: AudioFocusRequest? = null + @Volatile + private var focusLostTransiently = false + + private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + Log.i(TAG, "Audio focus LOSS — stopping") + handleStop() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Log.i(TAG, "Audio focus LOSS_TRANSIENT — pausing") + focusLostTransiently = true + engine?.setVolume(0f) + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + Log.i(TAG, "Audio focus LOSS_TRANSIENT_CAN_DUCK — ducking") + engine?.setVolume(0.2f) + } + AudioManager.AUDIOFOCUS_GAIN -> { + Log.i(TAG, "Audio focus GAIN — restoring") + focusLostTransiently = false + engine?.setVolume(1f) + } + } + } + private fun transition(newState: PlaybackState) { Log.i(TAG, "transition → $newState") controller.updateState(newState) @@ -127,6 +158,7 @@ class RadioPlaybackService : MediaLibraryService() { override fun onCreate() { super.onCreate() + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager ensureForegroundChannel() ensureMediaSession() } @@ -138,6 +170,41 @@ class RadioPlaybackService : MediaLibraryService() { getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } + override fun onTaskRemoved(rootIntent: Intent?) { + if (!stayConnected) { + Log.i(TAG, "onTaskRemoved — stopping (stayConnected=false)") + handleStop() + cleanupResources() + stopSelf() + } + super.onTaskRemoved(rootIntent) + } + + private fun requestAudioFocus(): Boolean { + val attrs = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attrs) + .setOnAudioFocusChangeListener(focusChangeListener) + .setWillPauseWhenDucked(false) + .build() + focusRequest = request + val result = audioManager?.requestAudioFocus(request) + val granted = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + Log.i(TAG, "requestAudioFocus: granted=$granted") + return granted + } + + private fun abandonAudioFocus() { + focusRequest?.let { + audioManager?.abandonAudioFocusRequest(it) + focusRequest = null + } + focusLostTransiently = false + } + private fun postPlaceholderForeground() { val notification = NotificationCompat.Builder(this, FOREGROUND_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) @@ -199,6 +266,8 @@ class RadioPlaybackService : MediaLibraryService() { } private fun cleanupResources() { + stopForeground(STOP_FOREGROUND_REMOVE) + abandonAudioFocus() engine?.stop() engine = null releaseLocks() @@ -225,6 +294,14 @@ class RadioPlaybackService : MediaLibraryService() { playerAdapter?.updateStation(station) playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station)) + if (!requestAudioFocus()) { + Log.w(TAG, "Audio focus denied — not starting playback") + transition(PlaybackState.Idle) + cleanupResources() + stopSelf() + return + } + try { val urls = app.streamResolver.resolveUrls(station) Log.i(TAG, "handlePlay resolved ${urls.size} URLs for '${station.name}': ${urls.take(3)}") @@ -276,6 +353,7 @@ class RadioPlaybackService : MediaLibraryService() { } private fun acquireLocks() { + releaseLocks() val pm = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Radio247:Playback").apply { acquire(10 * 60 * 1000L) diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt new file mode 100644 index 0000000..23a1eb2 --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/audio/AudioEngineVolumeTest.kt @@ -0,0 +1,27 @@ +package xyz.cottongin.radio247.audio + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioEngineVolumeTest { + + @Test + fun setVolumeDoesNotCrashWhenNoTrack() { + val engine = AudioEngine("http://example.com/stream") + engine.setVolume(0.5f) + engine.setVolume(0f) + engine.setVolume(1f) + } + + @Test + fun volumeClampValues() { + val clamped1 = (-0.5f).coerceIn(0f, 1f) + assertEquals(0f, clamped1, 0.001f) + + val clamped2 = (1.5f).coerceIn(0f, 1f) + assertEquals(1f, clamped2, 0.001f) + + val clamped3 = (0.2f).coerceIn(0f, 1f) + assertEquals(0.2f, clamped3, 0.001f) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt b/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt index c358d4a..ef2fe3b 100644 --- a/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt +++ b/app/src/test/java/xyz/cottongin/radio247/service/PlaybackStateMachineTest.kt @@ -160,4 +160,29 @@ class PlaybackStateMachineTest { assertTrue("Should be Idle after stop from $state", stateFlow.value is PlaybackState.Idle) } } + + @Test + fun `audio focus loss during playback transitions to idle`() { + val now = System.currentTimeMillis() + stateFlow.value = PlaybackState.Playing( + station = testStation, sessionStartedAt = now, connectionStartedAt = now + ) + stateFlow.value = PlaybackState.Idle + assertTrue(stateFlow.value is PlaybackState.Idle) + } + + @Test + fun `audio focus denied prevents transition from idle`() { + stateFlow.value = PlaybackState.Idle + assertTrue(stateFlow.value is PlaybackState.Idle) + } + + @Test + fun `audio focus loss transient during playback stays playing`() { + val now = System.currentTimeMillis() + stateFlow.value = PlaybackState.Playing( + station = testStation, sessionStartedAt = now, connectionStartedAt = now + ) + assertTrue(stateFlow.value is PlaybackState.Playing) + } }