summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle.kts4
-rw-r--r--app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt132
-rw-r--r--app/src/main/AndroidManifest.xml22
-rw-r--r--app/src/main/java/org/terst/listen/playback/LibraryTree.kt82
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt220
-rw-r--r--app/src/main/java/org/terst/listen/playback/NotificationControls.kt44
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt16
-rw-r--r--app/src/main/res/xml/automotive_app_desc.xml7
-rw-r--r--app/src/test/java/org/terst/listen/ReachabilityTest.kt15
9 files changed, 486 insertions, 56 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 97a89eb..b8bfb12 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -104,6 +104,10 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
+ // Bridges suspend functions to the Guava ListenableFuture that Media3's browse callbacks
+ // return. kotlinx-coroutines-jdk8's future builder produces a CompletableFuture, which is
+ // the wrong type here.
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
diff --git a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
new file mode 100644
index 0000000..640849a
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
@@ -0,0 +1,132 @@
+package org.terst.listen.playback
+
+import android.content.ComponentName
+import androidx.media3.session.MediaBrowser
+import androidx.media3.session.SessionToken
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.guava.await
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.terst.listen.data.Graph
+
+/**
+ * Browses the media library the way Android Auto does: over the session protocol from a
+ * MediaBrowser, not by calling the callback directly.
+ *
+ * Reported from the car: Auto did not recognise Listen as driving-friendly, so it could only be
+ * started and controlled from the phone. That needs a browsable library, and "it compiles" says
+ * nothing about whether a browser can connect and walk the tree.
+ *
+ * Everything runs on the main thread and awaits rather than blocks. MediaBrowser rejects calls
+ * from another thread outright, and blocking the main thread for a future that completes on it
+ * deadlocks.
+ */
+@RunWith(AndroidJUnit4::class)
+class MediaBrowseTest {
+
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ private suspend fun connect(): MediaBrowser = withContext(Dispatchers.Main) {
+ val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
+ MediaBrowser.Builder(context, token).buildAsync().await()
+ }
+
+ private suspend fun seedOneSubscription() {
+ Graph.get(context).repository.subscribe("https://feeds.npr.org/510318/podcast.xml")
+ }
+
+ @Test
+ fun aBrowserCanConnectAndWalkTheTree() = runBlocking {
+ seedOneSubscription()
+ val browser = connect()
+ try {
+ withContext(Dispatchers.Main) {
+ val root = browser.getLibraryRoot(null).await().value!!
+ assertEquals(LibraryTree.ROOT, root.mediaId)
+
+ val topIds = browser.getChildren(root.mediaId, 0, 20, null).await().value!!
+ .map { it.mediaId }
+ assertTrue(
+ "expected Up Next and Podcasts, got $topIds",
+ topIds.containsAll(listOf(LibraryTree.QUEUE, LibraryTree.SUBSCRIPTIONS)),
+ )
+
+ val shows = browser.getChildren(LibraryTree.SUBSCRIPTIONS, 0, 20, null).await().value!!
+ assertTrue("a car needs at least one show to pick", shows.isNotEmpty())
+ assertTrue("shows must be browsable", shows.first().mediaMetadata.isBrowsable == true)
+
+ val episodes = browser.getChildren(shows.first().mediaId, 0, 20, null).await().value!!
+ assertTrue("a show must list episodes", episodes.isNotEmpty())
+ assertTrue("episodes must be playable", episodes.first().mediaMetadata.isPlayable == true)
+ }
+ } finally {
+ withContext(Dispatchers.Main) { browser.release() }
+ }
+ }
+
+ /** Voice is the safe interaction in a car, so "play Up First" has to reach something. */
+ @Test
+ fun searchFindsAShowByName() = runBlocking {
+ seedOneSubscription()
+ val browser = connect()
+ try {
+ withContext(Dispatchers.Main) {
+ browser.search("Up First", null).await()
+ val results = browser.getSearchResult("Up First", 0, 20, null).await().value!!
+ assertTrue("search for a subscribed show should hit", results.isNotEmpty())
+ assertTrue(
+ "a show hit should be browsable so the driver can still choose an episode",
+ results.any { it.mediaMetadata.isBrowsable == true },
+ )
+ }
+ } finally {
+ withContext(Dispatchers.Main) { browser.release() }
+ }
+ }
+
+ @Test
+ fun searchForSomethingAbsentReturnsNothing() = runBlocking {
+ seedOneSubscription()
+ val browser = connect()
+ try {
+ withContext(Dispatchers.Main) {
+ browser.search("zzzz-not-a-show", null).await()
+ val results = browser.getSearchResult("zzzz-not-a-show", 0, 20, null).await().value
+ assertTrue("expected no hits, got ${results?.size}", results.isNullOrEmpty())
+ }
+ } finally {
+ withContext(Dispatchers.Main) { browser.release() }
+ }
+ }
+
+ /**
+ * A car hands back the browse id, which carries no URI. Without translation it lists
+ * episodes and then fails to play any of them.
+ */
+ @Test
+ fun aBrowsedEpisodeResolvesToSomethingPlayable() = runBlocking {
+ seedOneSubscription()
+ val browser = connect()
+ try {
+ withContext(Dispatchers.Main) {
+ val shows = browser.getChildren(LibraryTree.SUBSCRIPTIONS, 0, 5, null).await().value!!
+ val episodes = browser.getChildren(shows.first().mediaId, 0, 5, null).await().value!!
+
+ val item = browser.getItem(episodes.first().mediaId).await().value!!
+ assertTrue("browsed item should be playable", item.mediaMetadata.isPlayable == true)
+ assertTrue(
+ "the browse id must survive a round trip",
+ LibraryTree.episodeId(item.mediaId) != null,
+ )
+ }
+ } finally {
+ withContext(Dispatchers.Main) { browser.release() }
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ac8bb04..b92be67 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,13 @@
android:supportsRtl="true"
android:theme="@style/Theme.Listen">
+ <!-- Declares Listen as a media app to Android Auto. The head unit reads this before it
+ will show the app at all, and it must sit at application level — inside an
+ activity it is simply ignored. -->
+ <meta-data
+ android:name="com.google.android.gms.car.application"
+ android:resource="@xml/automotive_app_desc" />
+
<activity
android:name=".ui.MainActivity"
android:exported="true"
@@ -28,6 +35,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
+ <!-- "Play X on Listen" from the system or a head unit. In a car this is the only
+ interaction that keeps your eyes on the road. -->
+ <intent-filter>
+ <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
</activity>
<!-- Share-sheet target. Listen has no discovery, so this and OPML import are how
@@ -45,12 +58,17 @@
</intent-filter>
</activity>
+ <!-- Exported, because the controller here is another process: Android Auto, Assistant
+ or a Bluetooth remote binding from outside the app. A media service that is not
+ exported simply never gets found by a car. -->
<service
android:name=".playback.PlaybackService"
- android:exported="false"
+ android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
- <action android:name="androidx.media3.session.MediaSessionService" />
+ <action android:name="androidx.media3.session.MediaLibraryService" />
+ <!-- The legacy action, which is what Android Auto actually looks for. -->
+ <action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
diff --git a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
new file mode 100644
index 0000000..3c9f8f4
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
@@ -0,0 +1,82 @@
+package org.terst.listen.playback
+
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * The browsable tree Android Auto shows.
+ *
+ * Auto will not treat an app as driving-friendly unless it can browse a media library, so this
+ * is what turns Listen from "control it on your phone" into something the head unit can drive.
+ *
+ * Shallow on purpose: a driver picking from a list has a few seconds of attention, and Auto
+ * caps how much it will show anyway. Up Next comes first because in a car it is nearly always
+ * what you want.
+ */
+object LibraryTree {
+
+ const val ROOT = "listen:root"
+ const val QUEUE = "listen:queue"
+ const val SUBSCRIPTIONS = "listen:subscriptions"
+ private const val SUBSCRIPTION_PREFIX = "listen:subscription:"
+ private const val EPISODE_PREFIX = "listen:episode:"
+
+ fun subscriptionId(mediaId: String): String? =
+ mediaId.removePrefix(SUBSCRIPTION_PREFIX).takeIf { mediaId.startsWith(SUBSCRIPTION_PREFIX) }
+
+ fun episodeId(mediaId: String): String? =
+ mediaId.removePrefix(EPISODE_PREFIX).takeIf { mediaId.startsWith(EPISODE_PREFIX) }
+
+ fun root(): MediaItem = browsable(ROOT, "Listen")
+
+ fun topLevel(): List<MediaItem> = listOf(
+ browsable(QUEUE, "Up Next"),
+ browsable(SUBSCRIPTIONS, "Podcasts"),
+ )
+
+ fun subscription(entity: SubscriptionEntity): MediaItem =
+ browsable(SUBSCRIPTION_PREFIX + entity.id, entity.title, entity.author, entity.imageUrl)
+
+ /**
+ * An episode, playable.
+ *
+ * The media id carries the episode id rather than the URL: the tree is rebuilt on every
+ * browse, and an id survives a publisher re-hosting the file where a URL would not.
+ */
+ fun episode(entity: EpisodeEntity, showTitle: String? = null): MediaItem =
+ MediaItem.Builder()
+ .setMediaId(EPISODE_PREFIX + entity.id)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setTitle(entity.title)
+ .setArtist(showTitle)
+ .setAlbumTitle(showTitle)
+ .setArtworkUri(entity.imageUrl?.let(android.net.Uri::parse))
+ .setIsBrowsable(false)
+ .setIsPlayable(true)
+ .setMediaType(MediaMetadata.MEDIA_TYPE_PODCAST_EPISODE)
+ .build()
+ )
+ .build()
+
+ private fun browsable(
+ id: String,
+ title: String,
+ subtitle: String? = null,
+ artworkUrl: String? = null,
+ ): MediaItem = MediaItem.Builder()
+ .setMediaId(id)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ .setArtworkUri(artworkUrl?.let(android.net.Uri::parse))
+ .setIsBrowsable(true)
+ .setIsPlayable(false)
+ .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS)
+ .build()
+ )
+ .build()
+}
diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
new file mode 100644
index 0000000..9ca9fa3
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -0,0 +1,220 @@
+package org.terst.listen.playback
+
+import android.content.Context
+import android.os.Bundle
+import androidx.annotation.OptIn
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaLibraryService
+import androidx.media3.session.MediaSession
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionError
+import androidx.media3.session.SessionResult
+import com.google.common.collect.ImmutableList
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.guava.future
+import org.terst.listen.data.Graph
+
+/**
+ * What a controller -- the notification, a Bluetooth remote, or a car head unit -- is allowed to
+ * do, and what it sees when it browses.
+ */
+@OptIn(UnstableApi::class)
+class ListenSessionCallback(
+ private val context: Context,
+ private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main),
+) : MediaLibraryService.MediaLibrarySession.Callback {
+
+ private val graph by lazy { Graph.get(context) }
+
+ override fun onConnect(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ ): MediaSession.ConnectionResult {
+ // DEFAULT_SESSION_AND_LIBRARY_COMMANDS, not DEFAULT_SESSION_COMMANDS: the latter omits
+ // the browse commands, so a car connects successfully and then gets PERMISSION_DENIED
+ // the moment it asks for the library root. The connection looks fine; browsing is dead.
+ val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
+ .add(NotificationControls.seekBackCommand)
+ .add(NotificationControls.seekForwardCommand)
+ .build()
+
+ // A car head unit shows about three transport controls. Leave previous/next track
+ // available and they take those slots, pushing skip-forward off the screen entirely --
+ // which is exactly what happened: previous and skip-back, no skip-forward. For a single
+ // episode "previous track" does nothing useful anyway, so the seek buttons get the room.
+ val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
+ .remove(Player.COMMAND_SEEK_TO_PREVIOUS)
+ .remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
+ .remove(Player.COMMAND_SEEK_TO_NEXT)
+ .remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
+ .build()
+
+ return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
+ .setAvailableSessionCommands(sessionCommands)
+ .setAvailablePlayerCommands(playerCommands)
+ .setCustomLayout(ImmutableList.copyOf(NotificationControls.customLayout()))
+ .build()
+ }
+
+ override fun onCustomCommand(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ customCommand: SessionCommand,
+ args: Bundle,
+ ): ListenableFuture<SessionResult> {
+ when (customCommand.customAction) {
+ NotificationControls.ACTION_SEEK_BACK -> session.player.seekBack()
+ NotificationControls.ACTION_SEEK_FORWARD -> session.player.seekForward()
+ else -> return Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
+ }
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ }
+
+ // --- browsing ---------------------------------------------------------------------------
+
+ override fun onGetLibraryRoot(
+ session: MediaLibraryService.MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ params: MediaLibraryService.LibraryParams?,
+ ): ListenableFuture<LibraryResult<MediaItem>> =
+ Futures.immediateFuture(LibraryResult.ofItem(LibraryTree.root(), params))
+
+ override fun onGetChildren(
+ session: MediaLibraryService.MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ parentId: String,
+ page: Int,
+ pageSize: Int,
+ params: MediaLibraryService.LibraryParams?,
+ ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
+ val children = when {
+ parentId == LibraryTree.ROOT -> LibraryTree.topLevel()
+
+ parentId == LibraryTree.QUEUE ->
+ graph.queueDao.getEntries().mapNotNull { entry ->
+ graph.episodes.getById(entry.episodeId)?.let { episode ->
+ LibraryTree.episode(episode, showTitleOf(episode.subscriptionId))
+ }
+ }
+
+ parentId == LibraryTree.SUBSCRIPTIONS ->
+ graph.subscriptions.getAll().map(LibraryTree::subscription)
+
+ LibraryTree.subscriptionId(parentId) != null -> {
+ val id = LibraryTree.subscriptionId(parentId)!!
+ val showTitle = showTitleOf(id)
+ // Newest first, and capped: a feed here held 2753 episodes, and neither a car
+ // screen nor the browser protocol wants that list.
+ graph.episodes.getForSubscription(id)
+ .sortedByDescending { it.publishedAtMs ?: 0 }
+ .take(MAX_EPISODES)
+ .map { LibraryTree.episode(it, showTitle) }
+ }
+
+ else -> emptyList()
+ }
+ LibraryResult.ofItemList(ImmutableList.copyOf(children), params)
+ }
+
+ override fun onGetItem(
+ session: MediaLibraryService.MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ mediaId: String,
+ ): ListenableFuture<LibraryResult<MediaItem>> = scope.future {
+ val episodeId = LibraryTree.episodeId(mediaId)
+ val episode = episodeId?.let { graph.episodes.getById(it) }
+ if (episode == null) {
+ LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)
+ } else {
+ LibraryResult.ofItem(LibraryTree.episode(episode, showTitleOf(episode.subscriptionId)), null)
+ }
+ }
+
+ /**
+ * Turns the browse-tree ids a controller sends back into real playable items.
+ *
+ * Without this a car can list episodes and then fail to play them: the id it hands back is
+ * the tree's id, which carries no URI.
+ */
+ override fun onAddMediaItems(
+ mediaSession: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ mediaItems: MutableList<MediaItem>,
+ ): ListenableFuture<MutableList<MediaItem>> = scope.future {
+ mediaItems.mapNotNull { item ->
+ val episodeId = LibraryTree.episodeId(item.mediaId) ?: return@mapNotNull item
+ val episode = graph.episodes.getById(episodeId) ?: return@mapNotNull null
+ episode.toMediaItem(
+ showTitle = showTitleOf(episode.subscriptionId),
+ author = graph.subscriptions.getById(episode.subscriptionId)?.author,
+ )
+ }.toMutableList()
+ }
+
+ // --- search -----------------------------------------------------------------------------
+
+ /**
+ * Voice search, which in a car is the only interaction that doesn't take your eyes off the
+ * road. "Play Up First" has to reach something.
+ */
+ override fun onSearch(
+ session: MediaLibraryService.MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ query: String,
+ params: MediaLibraryService.LibraryParams?,
+ ): ListenableFuture<LibraryResult<Void>> = scope.future {
+ val hits = matches(query)
+ session.notifySearchResultChanged(browser, query, hits.size, params)
+ LibraryResult.ofVoid()
+ }
+
+ override fun onGetSearchResult(
+ session: MediaLibraryService.MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ query: String,
+ page: Int,
+ pageSize: Int,
+ params: MediaLibraryService.LibraryParams?,
+ ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
+ LibraryResult.ofItemList(ImmutableList.copyOf(matches(query)), params)
+ }
+
+ /**
+ * Shows first, then episodes.
+ *
+ * Asking for a show by name is the common case, and a show is browsable, so a hit lets the
+ * driver pick an episode rather than committing to whichever one matched a substring.
+ */
+ private suspend fun matches(query: String): List<MediaItem> {
+ val needle = query.trim().lowercase()
+ if (needle.isEmpty()) return emptyList()
+
+ val shows = graph.subscriptions.getAll()
+ val showHits = shows.filter { it.title.lowercase().contains(needle) }
+
+ val episodeHits = shows.flatMap { show ->
+ graph.episodes.getForSubscription(show.id)
+ .filter { it.title.lowercase().contains(needle) }
+ .sortedByDescending { it.publishedAtMs ?: 0 }
+ .take(MAX_EPISODE_HITS_PER_SHOW)
+ .map { LibraryTree.episode(it, show.title) }
+ }
+
+ return (showHits.map(LibraryTree::subscription) + episodeHits).take(MAX_SEARCH_RESULTS)
+ }
+
+ private suspend fun showTitleOf(subscriptionId: String): String? =
+ graph.subscriptions.getById(subscriptionId)?.title
+
+ private companion object {
+ const val MAX_EPISODES = 100
+ const val MAX_EPISODE_HITS_PER_SHOW = 5
+ const val MAX_SEARCH_RESULTS = 30
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/NotificationControls.kt b/app/src/main/java/org/terst/listen/playback/NotificationControls.kt
index cfad449..5c8d7c5 100644
--- a/app/src/main/java/org/terst/listen/playback/NotificationControls.kt
+++ b/app/src/main/java/org/terst/listen/playback/NotificationControls.kt
@@ -2,14 +2,9 @@ package org.terst.listen.playback
import android.os.Bundle
import androidx.annotation.OptIn
-import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
-import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
-import androidx.media3.session.SessionResult
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
/**
* Seek buttons for the notification, lock screen and Bluetooth controls.
@@ -29,8 +24,8 @@ object NotificationControls {
const val ACTION_SEEK_BACK = "org.terst.listen.SEEK_BACK"
const val ACTION_SEEK_FORWARD = "org.terst.listen.SEEK_FORWARD"
- private val seekBackCommand = SessionCommand(ACTION_SEEK_BACK, Bundle.EMPTY)
- private val seekForwardCommand = SessionCommand(ACTION_SEEK_FORWARD, Bundle.EMPTY)
+ val seekBackCommand = SessionCommand(ACTION_SEEK_BACK, Bundle.EMPTY)
+ val seekForwardCommand = SessionCommand(ACTION_SEEK_FORWARD, Bundle.EMPTY)
/**
* Media3 ships numbered icons for 5, 10, 15 and 30 seconds only.
@@ -51,39 +46,4 @@ object NotificationControls {
.build(),
)
- /** Grants the custom commands and runs them. Without the grant the buttons do nothing. */
- fun callback(): MediaSession.Callback = object : MediaSession.Callback {
-
- override fun onConnect(
- session: MediaSession,
- controller: MediaSession.ControllerInfo,
- ): MediaSession.ConnectionResult {
- val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
- .add(seekBackCommand)
- .add(seekForwardCommand)
- .build()
- return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
- .setAvailableSessionCommands(commands)
- .build()
- }
-
- override fun onCustomCommand(
- session: MediaSession,
- controller: MediaSession.ControllerInfo,
- customCommand: SessionCommand,
- args: Bundle,
- ): ListenableFuture<SessionResult> {
- val player: Player = session.player
- when (customCommand.customAction) {
- ACTION_SEEK_BACK -> player.seekBack()
- ACTION_SEEK_FORWARD -> player.seekForward()
- // Anything else is a command we never published, so treat it as unknown
- // rather than inventing a result code lint has to guess at.
- else -> return Futures.immediateFuture(
- SessionResult(androidx.media3.session.SessionError.ERROR_NOT_SUPPORTED)
- )
- }
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
- }
- }
}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
index c395425..5746286 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -8,7 +8,7 @@ import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
-import androidx.media3.session.MediaSessionService
+import androidx.media3.session.MediaLibraryService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -27,10 +27,10 @@ import org.terst.listen.data.EpisodeEntity
* new-episode notifications.
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
-class PlaybackService : MediaSessionService() {
+class PlaybackService : MediaLibraryService() {
private var player: ExoPlayer? = null
- private var session: MediaSession? = null
+ private var session: MediaLibrarySession? = null
private var writer: PositionWriter? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@@ -54,11 +54,11 @@ class PlaybackService : MediaSessionService() {
.build()
player = exo
- session = MediaSession.Builder(this, exo)
- // Media3's default notification offers previous/next track, which for a single
- // loaded episode means one dead button. Podcast controls are seek-by-seconds.
+ // A library session rather than a plain one: Android Auto only treats an app as
+ // driving-friendly if it can browse a media library, and without that the head unit
+ // can't start or choose anything.
+ session = MediaLibrarySession.Builder(this, exo, ListenSessionCallback(this, scope))
.setCustomLayout(NotificationControls.customLayout())
- .setCallback(NotificationControls.callback())
.build()
val episodes = AppDatabase.get(this).episodes()
@@ -66,7 +66,7 @@ class PlaybackService : MediaSessionService() {
startPositionTicker()
}
- override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = session
/**
* Android calls this when the last controller disconnects with playback stopped. Flushing
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..6136c69
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Android Auto will not offer an app at all unless it finds this file and sees a media use
+ declared. Without it Listen is invisible to the head unit no matter how correct the media
+ session is. -->
+<automotiveApp>
+ <uses name="media" />
+</automotiveApp>
diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
index d129756..ccd603a 100644
--- a/app/src/test/java/org/terst/listen/ReachabilityTest.kt
+++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
@@ -63,10 +63,17 @@ class ReachabilityTest {
return memberFun.findAll(slice)
.map { "$objectName.${it.groupValues[1]}" }
.filterNot { it in allowed }
- // The open paren matters: a bare name also matches a KDoc cross-reference like
- // [SubscriptionResolver.findFeedLink], which made an earlier version of this check
- // pass on the very dead code it was written to catch.
- .filterNot { qualified -> bodies.any { (other, b) -> other != file && b.contains("$qualified(") } }
+ // Two call shapes, both required. The open paren rules out a KDoc cross-reference
+ // like [SubscriptionResolver.findFeedLink], which made an earlier version of this
+ // check pass on the very dead code it existed to catch. The `::` form covers a
+ // method reference such as `map(LibraryTree::subscription)`, which has no parens at
+ // all and was reported as dead while being called.
+ .filterNot { qualified ->
+ val reference = qualified.replace(".", "::")
+ bodies.any { (other, b) ->
+ other != file && (b.contains("$qualified(") || b.contains(reference))
+ }
+ }
.toList()
}