Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions app/src/test/java/com/jbgsoft/ambio/AmbioManifestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion feature/tile/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
15 changes: 15 additions & 0 deletions feature/tile/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@
</intent-filter>
</receiver>

<!-- The tile's cold-start path. Declared here, not in app/, because unlike
PlaybackTile it needs no app resource: the theme is the framework's.
taskAffinity="" keeps it out of the main task so it never drags the
app UI to the front; showWhenLocked lets a lock-screen tap play
without unlocking; noHistory + excludeFromRecents keep the
millisecond-long activity out of Back and Recents. -->
<activity
android:name=".TilePlayActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:showWhenLocked="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
52 changes: 50 additions & 2 deletions media/src/main/java/com/jbgsoft/ambio/media/AudioService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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"
}
}
11 changes: 5 additions & 6 deletions media/src/main/java/com/jbgsoft/ambio/media/MixPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
22 changes: 22 additions & 0 deletions media/src/main/java/com/jbgsoft/ambio/media/StopOnDisconnect.kt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading