diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 20:25:01 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 20:25:01 +0000 |
| commit | 8d980bb234b8303f17908621501620cb898d17ef (patch) | |
| tree | 0a6e09addc5f1d4ad70beb1f50461cc2fdf7e5bd | |
| parent | f7e7719bd1ed8a40765ce8b31ef8fa2c600bc7d0 (diff) | |
Android Auto support, and give the car its skip-forward button
Two things from the car.
Skip-forward was missing: a head unit shows about three transport controls,
and leaving previous/next track available meant they took the slots. Previous
does nothing useful for a single episode anyway, so those commands are now
removed in onConnect and the seek buttons get the room.
Auto did not recognise Listen as driving-friendly, so it could only be
controlled from the phone. That needs a browsable library, which means a
MediaLibraryService rather than a MediaSessionService, plus a tree: Up Next
and Podcasts at the root, shows beneath, episodes beneath those, capped at 100
because one real feed here holds 2753.
Three things were each individually fatal and none failed loudly:
- The automotive_app_desc meta-data has to sit at application level. A
search-and-replace put a copy inside each activity as well, where it is
simply ignored.
- The service must be exported, because the controller is another process.
- onConnect must grant DEFAULT_SESSION_AND_LIBRARY_COMMANDS. With plain
DEFAULT_SESSION_COMMANDS a browser connects successfully and then gets
PERMISSION_DENIED on the library root -- the connection looks healthy and
browsing is dead. Found by logging the result code rather than assuming.
Voice search too, since in a car it is the only interaction that keeps your
eyes on the road. Shows rank before episodes: asking for a show by name is the
common case, and a show is browsable, so the driver still picks the episode.
MediaBrowseTest drives all of this over the real session protocol from a
MediaBrowser. It must run on the main thread and await rather than block --
MediaController rejects calls from another thread, and blocking the main
thread on a future that completes there deadlocks.
Also: XML comments cannot contain "--", which made the manifest unparseable
with a merge error that never named the line; and the reachability guard was
reporting a method reference (LibraryTree::subscription) as dead because it
only matched call syntax with parens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
| -rw-r--r-- | .coverage-baseline | 2 | ||||
| -rw-r--r-- | CLAUDE.md | 15 | ||||
| -rw-r--r-- | app/build.gradle.kts | 4 | ||||
| -rw-r--r-- | app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt | 132 | ||||
| -rw-r--r-- | app/src/main/AndroidManifest.xml | 22 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/playback/LibraryTree.kt | 82 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt | 220 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/playback/NotificationControls.kt | 44 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/playback/PlaybackService.kt | 16 | ||||
| -rw-r--r-- | app/src/main/res/xml/automotive_app_desc.xml | 7 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/listen/ReachabilityTest.kt | 15 |
11 files changed, 502 insertions, 57 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 41b3946..446e069 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -69.05 +62.10 @@ -143,6 +143,21 @@ intro/outro skipping. Schema fields for manual skip offsets exist but nothing re - **Feeds are big.** A real subscription in testing produced 2753 episodes in one refresh. Any per-episode work — a query in a loop, an O(n²) match — needs to be considered at that scale. +- **The service is a `MediaLibraryService`, not a `MediaSessionService`.** Android Auto only + treats an app as driving-friendly if it can browse a media library. Three things are each + individually fatal and none of them fail loudly: the `automotive_app_desc.xml` meta-data must + sit at *application* level, the service must be `exported` (the controller is another + process), and `onConnect` must grant `DEFAULT_SESSION_AND_LIBRARY_COMMANDS` — with plain + `DEFAULT_SESSION_COMMANDS` a car connects successfully and then gets PERMISSION_DENIED on the + library root. `MediaBrowseTest` walks the tree over the real protocol to catch all three. + +- **Car head units show about three transport controls.** Leave `COMMAND_SEEK_TO_PREVIOUS`/`NEXT` + available and they take the slots, pushing skip-forward off the screen. They are removed in + `onConnect` so the seek buttons get the room. + +- **XML comments cannot contain `--`.** The house prose style uses it constantly and it makes + the manifest unparseable, with a merge error that doesn't name the line. + - **The playing episode is always the head of the queue.** `advance()` drops the head, so if something played from a show list isn't there, finishing it deletes a queued episode unplayed. `play()` calls `playNow()` first to hold that invariant. Don't add a playback entry point that 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() } |
