diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png index 7d567607..25ab4393 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 2cb582ac..e8b8d9d6 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png index f58c67d4..6a6d7b5b 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 3a82cbe1..725e29bc 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png index da091612..a4cf46f7 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index 105df954..9673611f 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png index 2b048a8c..1564ae0f 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index 82e2d40b..9f1941e1 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png index eeaa24fb..c67bc965 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 42500c0a..b25848b2 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/test/java/com/jbgsoft/ambio/AmbioManifestTest.kt b/app/src/test/java/com/jbgsoft/ambio/AmbioManifestTest.kt index 0deeae43..c61a1f93 100644 --- a/app/src/test/java/com/jbgsoft/ambio/AmbioManifestTest.kt +++ b/app/src/test/java/com/jbgsoft/ambio/AmbioManifestTest.kt @@ -60,6 +60,17 @@ class AmbioManifestTest { .contains("com.jbgsoft.ambio.feature.tile.PlaybackTile") } + @Test + fun `declares the tile's trampoline activity, without which a cold tile tap throws`() { + val activities = context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES) + .activities + .orEmpty() + + assertThat(activities.map { it.name }) + .contains("com.jbgsoft.ambio.feature.tile.TilePlayActivity") + } + @Test fun `guards the tile service behind the system-only bind permission`() { val tile = context.packageManager diff --git a/feature/tile/build.gradle.kts b/feature/tile/build.gradle.kts index e2bcbdec..95f1fa34 100644 --- a/feature/tile/build.gradle.kts +++ b/feature/tile/build.gradle.kts @@ -7,9 +7,17 @@ android { } dependencies { - // For AudioService.ACTION_PLAYBACK_CHANGED / EXTRA_IS_PLAYING. + // For AudioService.ACTION_PLAYBACK_CHANGED / EXTRA_IS_PLAYING, and to target + // AudioService by class from TilePlayActivity. implementation(project(":media")) // Media3, for MediaButtonReceiver and the media-button intent implementation(libs.bundles.media3) + + // ContextCompat.startForegroundService + implementation(libs.androidx.core.ktx) + + testImplementation(libs.bundles.testing) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) } diff --git a/feature/tile/src/main/AndroidManifest.xml b/feature/tile/src/main/AndroidManifest.xml index 90d07dc6..beab517d 100644 --- a/feature/tile/src/main/AndroidManifest.xml +++ b/feature/tile/src/main/AndroidManifest.xml @@ -25,6 +25,21 @@ + + + diff --git a/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/PlaybackTile.kt b/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/PlaybackTile.kt index cd3d65e0..187b0f52 100644 --- a/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/PlaybackTile.kt +++ b/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/PlaybackTile.kt @@ -1,7 +1,12 @@ package com.jbgsoft.ambio.feature.tile +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService +import com.jbgsoft.ambio.media.AudioService /** * Quick Settings toggle for the mix. @@ -18,7 +23,10 @@ class PlaybackTile : TileService() { override fun onClick() { super.onClick() - sendBroadcast(playPauseIntent(this)) + when (TileTap.decide(isPlaying = PlaybackFlag.isPlaying(this))) { + TileTap.BROADCAST_TOGGLE -> sendBroadcast(playPauseIntent(this)) + TileTap.LAUNCH_PLAY -> launchPlay() + } // The tile flips immediately rather than waiting for the service to come up and // report back: the user tapped it, and a control that lags its own tap reads as // broken. The next onStartListening corrects it if the service disagreed. @@ -28,6 +36,33 @@ class PlaybackTile : TileService() { } } + /** + * API 34 replaced the Intent overload of startActivityAndCollapse with a PendingIntent + * one; the Intent overload is reached only below 34, where the PendingIntent overload + * does not exist; minSdk is 31. + */ + @SuppressLint("StartActivityAndCollapseDeprecated") + private fun launchPlay() { + // The category is how AudioService.onTaskRemoved tells this task apart from the + // app's; explicit intents resolve by component, so it changes nothing else. + val intent = Intent(this, TilePlayActivity::class.java) + .addCategory(AudioService.CATEGORY_TILE_TRAMPOLINE) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse( + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } + private fun refresh() { qsTile?.apply { state = if (PlaybackFlag.isPlaying(this@PlaybackTile)) { diff --git a/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/TilePlayActivity.kt b/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/TilePlayActivity.kt new file mode 100644 index 00000000..d2f09b66 --- /dev/null +++ b/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/TilePlayActivity.kt @@ -0,0 +1,38 @@ +package com.jbgsoft.ambio.feature.tile + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.content.ContextCompat +import com.jbgsoft.ambio.media.AudioService + +/** + * Starts the stored mix from the Quick Settings tile when the app is not running. + * + * This activity draws nothing (framework translucent theme, no content view) and + * finishes inside onCreate. It exists because Android 12+ lets a foreground app + * start a foreground service and refuses the same call from a background + * broadcast receiver, which is where the tile's media-button broadcast lands + * (issue #13). Being started from a tile makes the app user-visible for the + * one call that matters. + * + * The intent carries [AudioService.ACTION_PLAY_STORED_MIX], an explicit service action + * that [AudioService]'s onStartCommand handles directly. A media-button intent was + * tried first and rejected: Media3 1.10.1 intercepts a play key before it ever reaches + * the player when that player holds no media item, so on the cold-start empty player + * the stored-mix load never runs, startForeground() never happens, and the system + * kills the service with ForegroundServiceDidNotStartInTimeException. + */ +class TilePlayActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ContextCompat.startForegroundService(this, playIntent(this)) + finish() + } +} + +/** An explicit [AudioService.ACTION_PLAY_STORED_MIX] service intent aimed at [AudioService]. */ +internal fun playIntent(context: Context): Intent = + Intent(context, AudioService::class.java).setAction(AudioService.ACTION_PLAY_STORED_MIX) diff --git a/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/TileTap.kt b/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/TileTap.kt new file mode 100644 index 00000000..1525d0c3 --- /dev/null +++ b/feature/tile/src/main/java/com/jbgsoft/ambio/feature/tile/TileTap.kt @@ -0,0 +1,25 @@ +package com.jbgsoft.ambio.feature.tile + +/** + * What a tile tap does, decided from the last known playback state. + * + * A broadcast to Media3's MediaButtonReceiver can only start AudioService when + * the app is already a foreground process: from a background receiver, Android 12+ + * refuses the startForegroundService call and Media3 swallows the exception + * (issue #13). While the mix plays, the service is foreground and the broadcast + * works, and it leaves the Quick Settings panel open, which is the nicer pause. + * + * When nothing plays, the tile goes through TilePlayActivity instead: an activity + * started from a tile is user-visible, and a user-visible app may start a + * foreground service. The panel collapses, as it does for any tile that starts + * an activity. + */ +enum class TileTap { + BROADCAST_TOGGLE, + LAUNCH_PLAY; + + companion object { + fun decide(isPlaying: Boolean): TileTap = + if (isPlaying) BROADCAST_TOGGLE else LAUNCH_PLAY + } +} diff --git a/feature/tile/src/test/java/com/jbgsoft/ambio/feature/tile/TilePlayActivityTest.kt b/feature/tile/src/test/java/com/jbgsoft/ambio/feature/tile/TilePlayActivityTest.kt new file mode 100644 index 00000000..8695e313 --- /dev/null +++ b/feature/tile/src/test/java/com/jbgsoft/ambio/feature/tile/TilePlayActivityTest.kt @@ -0,0 +1,31 @@ +package com.jbgsoft.ambio.feature.tile + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.jbgsoft.ambio.media.AudioService +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TilePlayActivityTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `play intent is aimed at the service`() { + val intent = playIntent(context) + + assertThat(intent.component?.className).isEqualTo(AudioService::class.java.name) + } + + @Test + fun `play intent carries the stored-mix action`() { + val intent = playIntent(context) + + assertThat(intent.action).isEqualTo(AudioService.ACTION_PLAY_STORED_MIX) + } +} diff --git a/feature/tile/src/test/java/com/jbgsoft/ambio/feature/tile/TileTapTest.kt b/feature/tile/src/test/java/com/jbgsoft/ambio/feature/tile/TileTapTest.kt new file mode 100644 index 00000000..c2c19893 --- /dev/null +++ b/feature/tile/src/test/java/com/jbgsoft/ambio/feature/tile/TileTapTest.kt @@ -0,0 +1,17 @@ +package com.jbgsoft.ambio.feature.tile + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TileTapTest { + + @Test + fun `while playing, a tap is a broadcast toggle`() { + assertThat(TileTap.decide(isPlaying = true)).isEqualTo(TileTap.BROADCAST_TOGGLE) + } + + @Test + fun `while not playing, a tap launches play through an activity`() { + assertThat(TileTap.decide(isPlaying = false)).isEqualTo(TileTap.LAUNCH_PLAY) + } +} diff --git a/media/src/main/java/com/jbgsoft/ambio/media/AudioService.kt b/media/src/main/java/com/jbgsoft/ambio/media/AudioService.kt index 49559cb2..9de13b0b 100644 --- a/media/src/main/java/com/jbgsoft/ambio/media/AudioService.kt +++ b/media/src/main/java/com/jbgsoft/ambio/media/AudioService.kt @@ -122,9 +122,24 @@ class AudioService : MediaSessionService() { .build() } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val result = super.onStartCommand(intent, flags, startId) + if (intent?.action == ACTION_PLAY_STORED_MIX) { + // The service registers a session only when onGetSession hands it to a binding + // controller. Started by a bare action nothing binds, so without this the + // notification manager never attaches, startForeground never runs, and the + // system kills the service after the deadline. + mediaSession?.let { addSession(it) } + if (player.playbackState == Player.STATE_IDLE) loadStoredMixAndPlay() else player.play() + } + return result + } + /** - * Called when something asks this service to play while it holds no sounds — the - * Quick Settings tile with the app closed, in practice. + * Called to fill an empty player before playing it. Two callers reach it: the tile's + * ACTION_PLAY_STORED_MIX start in onStartCommand — the real cold-start path, the app + * not running at all — and MixPlayer's onPlayRequestedWithEmptyMix hook, an in-app + * play arriving at an already-running player that holds nothing. * * Starting the service is not enough on its own: it comes up empty, and an empty * player publishes an empty timeline, so Media3 shows no notification, so @@ -173,8 +188,17 @@ class AudioService : MediaSessionService() { } override fun onTaskRemoved(rootIntent: Intent?) { + // The tile's trampoline activity lives in its own task and finishes the moment it + // has started this service, so its task is removed while the stored mix is still + // loading. That removal says nothing about what the user wants; only the app's + // own task does. + if (rootIntent?.hasCategory(CATEGORY_TILE_TRAMPOLINE) == true) { + Log.d(TAG, "Trampoline task removed; ignoring") + return + } val player = mediaSession?.player if (player?.playWhenReady == false || player?.mediaItemCount == 0) { + Log.d(TAG, "Task removed with nothing playing; stopping") stopSelf() } } @@ -231,6 +255,21 @@ class AudioService : MediaSessionService() { } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + + override fun onDisconnected( + session: MediaSession, + controller: MediaSession.ControllerInfo + ) { + // See shouldStopOnDisconnect for why only idle, and why not the notification. + if (shouldStopOnDisconnect( + isNotificationController = session.isMediaNotificationController(controller), + playbackState = session.player.playbackState + ) + ) { + Log.d(TAG, "A controller left an idle player; stopping") + stopSelf() + } + } } companion object { @@ -246,5 +285,14 @@ class AudioService : MediaSessionService() { */ const val ACTION_PLAYBACK_CHANGED = "com.jbgsoft.ambio.PLAYBACK_CHANGED" const val EXTRA_IS_PLAYING = "is_playing" + + // The tile's cold-start path; a media-button intent cannot do this because Media3 + // intercepts play on an empty player before it reaches MixPlayer. + const val ACTION_PLAY_STORED_MIX = "com.jbgsoft.ambio.PLAY_STORED_MIX" + + // Carried by the intent that launches the tile's trampoline activity; onTaskRemoved + // uses it to recognise that task, whose removal means nothing about the user's + // intent. + const val CATEGORY_TILE_TRAMPOLINE = "com.jbgsoft.ambio.category.TILE_TRAMPOLINE" } } diff --git a/media/src/main/java/com/jbgsoft/ambio/media/MixPlayer.kt b/media/src/main/java/com/jbgsoft/ambio/media/MixPlayer.kt index 37b14b51..ef047a0c 100644 --- a/media/src/main/java/com/jbgsoft/ambio/media/MixPlayer.kt +++ b/media/src/main/java/com/jbgsoft/ambio/media/MixPlayer.kt @@ -315,12 +315,11 @@ class MixPlayer( override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { if (playWhenReady) { - // Before anything else, and specifically before asking for audio focus. - // A play request can arrive from the Quick Settings tile with the app closed, - // and this player then holds nothing: taking focus at that point silences - // whatever the user was actually listening to, in order to play silence. - // The service owns the fix — it can read the stored mix, and this class - // deliberately cannot. + // This is the in-app case: a controller's play request reaching a player that + // holds nothing. Do not take audio focus for silence — that would steal it from + // whatever the user is actually listening to, in order to play nothing. The + // service owns the fix: it can read the stored mix, and this class deliberately + // cannot. if (entries.isEmpty()) { onPlayRequestedWithEmptyMix() return Futures.immediateVoidFuture() diff --git a/media/src/main/java/com/jbgsoft/ambio/media/StopOnDisconnect.kt b/media/src/main/java/com/jbgsoft/ambio/media/StopOnDisconnect.kt new file mode 100644 index 00000000..4aa4de49 --- /dev/null +++ b/media/src/main/java/com/jbgsoft/ambio/media/StopOnDisconnect.kt @@ -0,0 +1,22 @@ +package com.jbgsoft.ambio.media + +import androidx.media3.common.Player + +/** + * Whether AudioService should stop itself when a controller disconnects. + * + * Backing out of the app finishes the activity and releases its controller, but + * nothing removes the task, so onTaskRemoved never fires and the service lives on + * with nothing to do (issue #9). This is the back-out counterpart. + * + * Only STATE_IDLE counts: that is what MixPlayer reports once Stop has released + * every track. A paused mix is STATE_READY and keeps its notification, and a + * process behind it, so the user can resume from the notification or the tile. + * + * Media3's own notification connects as a controller and comes and goes on its + * own schedule; it must never be the trigger. + */ +internal fun shouldStopOnDisconnect( + isNotificationController: Boolean, + @Player.State playbackState: Int +): Boolean = !isNotificationController && playbackState == Player.STATE_IDLE diff --git a/media/src/test/java/com/jbgsoft/ambio/media/StopOnDisconnectTest.kt b/media/src/test/java/com/jbgsoft/ambio/media/StopOnDisconnectTest.kt new file mode 100644 index 00000000..d3a071bf --- /dev/null +++ b/media/src/test/java/com/jbgsoft/ambio/media/StopOnDisconnectTest.kt @@ -0,0 +1,33 @@ +package com.jbgsoft.ambio.media + +import androidx.media3.common.Player +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class StopOnDisconnectTest { + + @Test + fun `app controller leaving an idle player stops the service`() { + assertThat(shouldStopOnDisconnect(isNotificationController = false, playbackState = Player.STATE_IDLE)) + .isTrue() + } + + @Test + fun `app controller leaving a ready player keeps the service`() { + // Paused is STATE_READY with playWhenReady false: the notification stays and so do we. + assertThat(shouldStopOnDisconnect(isNotificationController = false, playbackState = Player.STATE_READY)) + .isFalse() + } + + @Test + fun `notification controller leaving an idle player keeps the service`() { + assertThat(shouldStopOnDisconnect(isNotificationController = true, playbackState = Player.STATE_IDLE)) + .isFalse() + } + + @Test + fun `notification controller leaving a ready player keeps the service`() { + assertThat(shouldStopOnDisconnect(isNotificationController = true, playbackState = Player.STATE_READY)) + .isFalse() + } +}