diff options
Diffstat (limited to 'app')
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() } |
