From 6579f008663f10bd406e5607735c186de381474d Mon Sep 17 00:00:00 2001 From: Netherwarlord Date: Mon, 21 Sep 2026 02:21:48 -0400 Subject: [PATCH] Polish Android Auto: playable tracks, artwork, album queueing Track MediaItems set neither isPlayable nor isBrowsable, which Media3 turns into Auto's item flags, so every song in an album/playlist/ download/search list reached the car as an inert row. Both are now set, along with duration and track number. Artwork used the in-app http://localhost placeholder URL, which only the app's signed OkHttpClient can resolve. Auto and System UI fetch artwork themselves from their own processes, so it never loaded (logcat: "Invalid album art uri"). Adds CoverArtProvider, an exported content:// provider that fetches art through the signed client and caches it, and uses that URI on all MediaItems. The session's bitmap loader now goes through the same DefaultDataSource + OkHttp factory as playback. Coil in the phone UI reads the same content:// URI. BrowseTree: - Tapping one track in the car now queues the whole album/playlist/ downloads/search list from that track (onSetMediaItems), keyed by browser package so the phone's own queues are untouched. - Artists/albums/playlists lay out as a cover grid. - Honours page/pageSize; fixes an Int overflow in the paging helper. - Failures return LibraryResult errors instead of failed futures, and cancelled requests cancel their work. - The logged-out placeholder is no longer a browsable empty folder. Tests: unit tests for paging and cover-art id validation, plus an instrumented test that drives the service as an external MediaBrowser (skips itself when logged out). Verified on an emulator against a live server: playback, system media card and Now Playing artwork, no crashes. Not verified over a real Android Auto / Desktop Head Unit connection. Co-Authored-By: Claude Sonnet 5 --- .../deepwave/AutoBrowseInstrumentedTest.kt | 134 ++++++++++++ app/src/main/AndroidManifest.xml | 8 + .../deepwave/media/BrowseTree.kt | 197 +++++++++++++++--- .../deepwave/media/CoverArtProvider.kt | 120 +++++++++++ .../deepwave/media/DeepwavePlaybackService.kt | 10 + .../deepwave/media/SubsonicMediaItemMapper.kt | 20 +- .../deepwave/media/BrowseTreeHelpersTest.kt | 47 +++++ 7 files changed, 498 insertions(+), 38 deletions(-) create mode 100644 app/src/androidTest/java/com/InfernalAquatics/deepwave/AutoBrowseInstrumentedTest.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/CoverArtProvider.kt create mode 100644 app/src/test/java/com/InfernalAquatics/deepwave/media/BrowseTreeHelpersTest.kt diff --git a/app/src/androidTest/java/com/InfernalAquatics/deepwave/AutoBrowseInstrumentedTest.kt b/app/src/androidTest/java/com/InfernalAquatics/deepwave/AutoBrowseInstrumentedTest.kt new file mode 100644 index 0000000..e3804aa --- /dev/null +++ b/app/src/androidTest/java/com/InfernalAquatics/deepwave/AutoBrowseInstrumentedTest.kt @@ -0,0 +1,134 @@ +package com.InfernalAquatics.deepwave + +import android.content.ComponentName +import android.graphics.BitmapFactory +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaBrowser +import androidx.media3.session.MediaConstants +import androidx.media3.session.SessionToken +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.InfernalAquatics.deepwave.media.DeepwavePlaybackService +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.ListenableFuture +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +/** + * Drives [DeepwavePlaybackService]'s browse tree the way Android Auto does - as an external + * [MediaBrowser] - since there's no head unit to test against. Needs a device where the app is + * already logged in to a reachable server; skips itself otherwise. + */ +@UnstableApi +@RunWith(AndroidJUnit4::class) +class AutoBrowseInstrumentedTest { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.targetContext + private lateinit var browser: MediaBrowser + + @Before + fun connect() { + val token = SessionToken(context, ComponentName(context, DeepwavePlaybackService::class.java)) + browser = onMain { MediaBrowser.Builder(context, token).buildAsync() }.get(15, TimeUnit.SECONDS) + } + + @After + fun release() { + instrumentation.runOnMainSync { + browser.clearMediaItems() + browser.release() + } + } + + @Test + fun rootShowsCategoriesWithGridLayoutForCollections() { + val root = children("root") + assumeTrue("App isn't logged in on this device", root.none { it.mediaId == "not_logged_in" }) + + assertEquals(listOf("playlists", "artists", "albums", "downloaded"), root.map { it.mediaId }) + assertTrue(root.all { it.mediaMetadata.isBrowsable == true && it.mediaMetadata.isPlayable == false }) + val artistsStyle = root.first { it.mediaId == "artists" }.mediaMetadata.extras + ?.getInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE) + assertEquals(MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM, artistsStyle) + } + + @Test + fun albumArtworkIsServedThroughTheContentProvider() { + val album = loggedInAlbums().first { it.mediaMetadata.artworkUri != null } + val artwork = album.mediaMetadata.artworkUri!! + assertEquals("content", artwork.scheme) + + val bitmap = context.contentResolver.openInputStream(artwork).use { BitmapFactory.decodeStream(it) } + assertNotNull("Provider should return a decodable image for $artwork", bitmap) + } + + @Test + fun tracksArePlayableAndPagingIsHonoured() { + val album = loggedInAlbums().first() + val tracks = children(album.mediaId) + assertTrue(tracks.isNotEmpty()) + assertTrue(tracks.all { it.mediaMetadata.isPlayable == true && it.mediaMetadata.isBrowsable == false }) + + assertEquals(3, children("albums", pageSize = 3).size) + } + + @Test + fun tappingOneTrackQueuesTheWholeAlbumFromThatTrack() { + val tracks = loggedInAlbums().asSequence() + .map { children(it.mediaId) } + .firstOrNull { it.size >= 3 } + assumeTrue("Need an album with at least 3 tracks", tracks != null) + tracks!! + + // Auto hands back just the one item it was shown, URI-less, exactly like this. + val tapped = tracks[2] + instrumentation.runOnMainSync { browser.setMediaItem(tapped) } + + val deadline = System.currentTimeMillis() + 10_000 + var count = 0 + var index = -1 + while (System.currentTimeMillis() < deadline && count != tracks.size) { + instrumentation.runOnMainSync { + count = browser.mediaItemCount + index = browser.currentMediaItemIndex + } + Thread.sleep(100) + } + assertEquals("Whole album should be queued", tracks.size, count) + assertEquals("Playback should start at the tapped track", 2, index) + assertFalse(index < 0) + } + + private fun loggedInAlbums(): List { + val root = children("root") + assumeTrue("App isn't logged in on this device", root.none { it.mediaId == "not_logged_in" }) + val albums = children("albums") + assumeTrue("Server has no albums", albums.isNotEmpty()) + return albums + } + + private fun children(parentId: String, pageSize: Int = Int.MAX_VALUE): ImmutableList { + val result: LibraryResult> = + onMain { browser.getChildren(parentId, 0, pageSize, null) }.get(30, TimeUnit.SECONDS) + assertEquals("getChildren($parentId)", LibraryResult.RESULT_SUCCESS, result.resultCode) + return result.value!! + } + + /** MediaBrowser calls must be made from the thread it was built on - the main thread here. */ + private fun onMain(block: () -> ListenableFuture): ListenableFuture { + lateinit var future: ListenableFuture + instrumentation.runOnMainSync { future = block() } + return future + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ec70b80..337ae54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,14 @@ android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc" /> + + + [Playlists, Artists, Albums, Downloaded] -> drill-down -> tracks. Built entirely on @@ -47,6 +57,15 @@ class BrowseTree @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** + * The last list of tracks each external browser (keyed by package) was shown, as full + * playable items. Android Auto asks to play a single tapped track, not the list it was in - + * this is how [onSetMediaItems] turns "play this track" into "play this album/playlist + * starting here". The phone app's own controller never browses, so it never has an entry + * and its own queues are passed through untouched. + */ + private val lastBrowsedTracks = ConcurrentHashMap>() + override fun onGetLibraryRoot( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, @@ -61,8 +80,10 @@ class BrowseTree @Inject constructor( page: Int, pageSize: Int, params: LibraryParams?, - ): ListenableFuture>> = scope.future { - LibraryResult.ofItemList(ImmutableList.copyOf(children(parentId)), params) + ): ListenableFuture>> = libraryFuture("getChildren($parentId)") { + val children = children(parentId) + if (children.isTrackList) lastBrowsedTracks[browser.packageName] = children.items + LibraryResult.ofItemList(ImmutableList.copyOf(children.items.page(page, pageSize)), params) } /** @@ -83,12 +104,37 @@ class BrowseTree @Inject constructor( return Futures.immediateFuture(resolved) } + /** + * Expands a single tapped track into the whole list it was browsed in, positioned at that + * track, so playback continues through the album/playlist instead of stopping after one song. + * Anything else (multi-item requests, ids not from the browser's last list) falls through to + * the default, which just runs [onAddMediaItems]. + */ + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: List, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture { + val queue = lastBrowsedTracks[controller.packageName] + if (queue != null && mediaItems.size == 1) { + val index = queue.indexOfFirst { it.mediaId == mediaItems.first().mediaId } + if (index >= 0) { + return Futures.immediateFuture( + MediaSession.MediaItemsWithStartPosition(queue, index, startPositionMs), + ) + } + } + return super.onSetMediaItems(mediaSession, controller, mediaItems, startIndex, startPositionMs) + } + override fun onSearch( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, params: LibraryParams?, - ): ListenableFuture> = scope.future { + ): ListenableFuture> = libraryFuture("search($query)") { val results = libraryRepository.search(query) session.notifySearchResultChanged(browser, query, results.songs.size, params) LibraryResult.ofVoid() @@ -101,79 +147,153 @@ class BrowseTree @Inject constructor( page: Int, pageSize: Int, params: LibraryParams?, - ): ListenableFuture>> = scope.future { - val results = libraryRepository.search(query) - LibraryResult.ofItemList(ImmutableList.copyOf(results.songs.map { it.toMediaItem() }), params) + ): ListenableFuture>> = libraryFuture("searchResult($query)") { + val children = songChildren(libraryRepository.search(query).songs) + lastBrowsedTracks[browser.packageName] = children.items + LibraryResult.ofItemList(ImmutableList.copyOf(children.items.page(page, pageSize)), params) } - private suspend fun children(parentId: String): List = when { - parentId == ROOT_ID -> rootChildren() - parentId == CATEGORY_PLAYLISTS -> playlistRepository.observePlaylists().first().map { playlist -> - browsableItem("$PREFIX_PLAYLIST${playlist.id}", playlist.name, coverArtUrl(playlist.coverArtId)) - } - parentId == CATEGORY_ARTISTS -> libraryRepository.getArtists().map { artist -> - browsableItem("$PREFIX_ARTIST${artist.id}", artist.name, coverArtUrl(artist.coverArtId)) - } - parentId == CATEGORY_ALBUMS -> libraryRepository.getAlbumList(AlbumListType.NEWEST, size = 50).map(::albumItem) - parentId == CATEGORY_DOWNLOADED -> downloadRepository.downloadedTracks().first() - .filter { it.status == DownloadStatus.COMPLETE } - .map { it.toMediaItem() } + /** [items] is what gets shown in the browser; [isTrackList] marks it as playable tracks, which + * become the browser's [lastBrowsedTracks]. */ + private class Children(val items: List, val isTrackList: Boolean = false) + + private suspend fun children(parentId: String): Children = when { + parentId == ROOT_ID -> Children(rootChildren()) + parentId == CATEGORY_PLAYLISTS -> Children( + playlistRepository.observePlaylists().first().map { playlist -> + browsableItem( + id = "$PREFIX_PLAYLIST${playlist.id}", + title = playlist.name, + artworkUri = coverArtContentUri(playlist.coverArtId), + subtitle = "${playlist.trackCount} tracks", + ) + }, + ) + parentId == CATEGORY_ARTISTS -> Children( + libraryRepository.getArtists().map { artist -> + browsableItem( + id = "$PREFIX_ARTIST${artist.id}", + title = artist.name, + artworkUri = coverArtContentUri(artist.coverArtId), + childrenAsGrid = true, + ) + }, + ) + parentId == CATEGORY_ALBUMS -> + Children(libraryRepository.getAlbumList(AlbumListType.NEWEST, size = 50).map(::albumItem)) + parentId == CATEGORY_DOWNLOADED -> downloadChildren( + downloadRepository.downloadedTracks().first().filter { it.status == DownloadStatus.COMPLETE }, + ) parentId.startsWith(PREFIX_PLAYLIST) -> - playlistRepository.observeTracks(parentId.removePrefix(PREFIX_PLAYLIST)).first().map { it.toMediaItem() } + songChildren(playlistRepository.observeTracks(parentId.removePrefix(PREFIX_PLAYLIST)).first()) parentId.startsWith(PREFIX_ARTIST) -> - libraryRepository.getArtistDetail(parentId.removePrefix(PREFIX_ARTIST)).second.map(::albumItem) + Children(libraryRepository.getArtistDetail(parentId.removePrefix(PREFIX_ARTIST)).second.map(::albumItem)) parentId.startsWith(PREFIX_ALBUM) -> - libraryRepository.getAlbumDetail(parentId.removePrefix(PREFIX_ALBUM)).second.map { it.toMediaItem() } - else -> emptyList() + songChildren(libraryRepository.getAlbumDetail(parentId.removePrefix(PREFIX_ALBUM)).second) + else -> Children(emptyList()) } private suspend fun rootChildren(): List { if (!serverRepository.isLoggedIn.first()) { - return listOf(browsableItem(NOT_LOGGED_IN_ID, "Log in on your phone first", playable = false)) + return listOf(infoItem(NOT_LOGGED_IN_ID, "Log in on your phone first")) } return listOf( - browsableItem(CATEGORY_PLAYLISTS, "Playlists"), - browsableItem(CATEGORY_ARTISTS, "Artists"), - browsableItem(CATEGORY_ALBUMS, "Albums"), + browsableItem(CATEGORY_PLAYLISTS, "Playlists", childrenAsGrid = true), + browsableItem(CATEGORY_ARTISTS, "Artists", childrenAsGrid = true), + browsableItem(CATEGORY_ALBUMS, "Albums", childrenAsGrid = true), browsableItem(CATEGORY_DOWNLOADED, "Downloaded"), ) } + private fun songChildren(songs: List) = Children(songs.map { it.toMediaItem() }, isTrackList = true) + + private fun downloadChildren(tracks: List) = + Children(tracks.map { it.toMediaItem() }, isTrackList = true) + private fun albumItem(album: Album) = browsableItem( id = "$PREFIX_ALBUM${album.id}", title = album.name, - artworkUrl = coverArtUrl(album.coverArtId), + artworkUri = coverArtContentUri(album.coverArtId), subtitle = album.artistName, ) private fun browsableItem( id: String, title: String, - artworkUrl: String? = null, + artworkUri: Uri? = null, subtitle: String? = null, - playable: Boolean = false, + childrenAsGrid: Boolean = false, ): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(title) .setArtist(subtitle) - .setIsBrowsable(!playable) - .setIsPlayable(playable) - .apply { artworkUrl?.let { setArtworkUri(Uri.parse(it)) } } + .setIsBrowsable(true) + .setIsPlayable(false) + .setArtworkUri(artworkUri) + .apply { + // Android Auto reads this off a browsable item as the layout for that item's own + // children: artist/album/playlist collections read better as a cover grid, while + // track lists (the default) stay as rows. + if (childrenAsGrid) { + setExtras( + Bundle().apply { + putInt( + MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM, + ) + }, + ) + } + } .build() return MediaItem.Builder().setMediaId(id).setMediaMetadata(metadata).build() } + /** A row that's neither browsable nor playable - just tells the user something. */ + private fun infoItem(id: String, title: String): MediaItem { + val metadata = MediaMetadata.Builder() + .setTitle(title) + .setIsBrowsable(false) + .setIsPlayable(false) + .build() + return MediaItem.Builder().setMediaId(id).setMediaMetadata(metadata).build() + } + + /** + * Runs [block] and turns any failure (offline, server error, expired login) into a proper + * [LibraryResult] error the car can show, instead of a failed future that surfaces as an + * opaque unknown error. + */ + private fun libraryFuture( + what: String, + block: suspend () -> LibraryResult, + ): ListenableFuture> = scope.future> { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "$what failed", e) + LibraryResult.ofError(SessionError.ERROR_IO) + } + } + /** Bridges a suspend block to the [ListenableFuture] Media3's callbacks require, with no new * dependency - [SettableFuture] is already transitively on the classpath via media3-session. */ private fun CoroutineScope.future(block: suspend () -> T): ListenableFuture { val future = SettableFuture.create() - launch { + val job = launch { try { future.set(block()) + } catch (e: CancellationException) { + future.cancel(false) + throw e } catch (e: Exception) { future.setException(e) } } + // Media3 cancels the future when the browser goes away or re-requests; stop the work too. + future.addListener({ if (future.isCancelled) job.cancel() }, MoreExecutors.directExecutor()) return future } @@ -189,3 +309,12 @@ class BrowseTree @Inject constructor( const val PREFIX_ALBUM = "album:" } } + +/** One page of a list; Android Auto's legacy browser path passes a page size of [Int.MAX_VALUE] + * (or 0), meaning "everything". */ +internal fun List.page(page: Int, pageSize: Int): List { + if (pageSize <= 0 || pageSize == Int.MAX_VALUE) return this + val from = (page.toLong() * pageSize).coerceIn(0, size.toLong()) + val to = (from + pageSize).coerceAtMost(size.toLong()) + return subList(from.toInt(), to.toInt()) +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/CoverArtProvider.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/CoverArtProvider.kt new file mode 100644 index 0000000..47d4fff --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/CoverArtProvider.kt @@ -0,0 +1,120 @@ +package com.InfernalAquatics.deepwave.media + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import com.InfernalAquatics.deepwave.BuildConfig +import com.InfernalAquatics.deepwave.data.network.coverArtUrl +import com.InfernalAquatics.deepwave.di.OkHttpClientEntryPoint +import dagger.hilt.EntryPoints +import okhttp3.Request +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException + +private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.coverart" +private const val CACHE_DIR = "coverart" +private const val MAX_CACHE_BYTES = 50L * 1024 * 1024 + +/** Subsonic cover-art ids are opaque tokens like `al-_`; anything else is rejected. */ +internal fun isValidCoverArtId(id: String): Boolean = + id.isNotEmpty() && id.length <= 128 && ".." !in id && id.all { it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' } + +/** + * The `content://` URI Android Auto is handed for browse-list artwork. Auto fetches item artwork + * itself, from its own process, so the in-app `http://localhost/...` URI from + * [com.InfernalAquatics.deepwave.data.network.coverArtUrl] (which only means something to our + * signed OkHttpClient) would never load there. + */ +fun coverArtContentUri(coverArtId: String?, size: Int = 300): Uri? = + coverArtId?.takeIf(::isValidCoverArtId)?.let { + Uri.Builder() + .scheme("content") + .authority(AUTHORITY) + .appendPath(it) + .appendQueryParameter("size", size.toString()) + .build() + } + +/** + * Serves cover art to other processes (Android Auto) by fetching it through the app's signed + * OkHttpClient and handing back a read-only file descriptor onto a cached copy. Exported so Auto + * can open it, which means any app can request art by id - so ids are validated and the provider + * only ever returns image bytes, never anything else from the server. + */ +class CoverArtProvider : ContentProvider() { + + override fun onCreate(): Boolean = true + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + if (mode != "r") throw FileNotFoundException("Read-only provider") + val id = uri.lastPathSegment?.takeIf(::isValidCoverArtId) + ?: throw FileNotFoundException("Invalid cover art id") + val size = uri.getQueryParameter("size")?.toIntOrNull()?.coerceIn(64, 1024) ?: 300 + + val dir = File(requireNotNull(context).cacheDir, CACHE_DIR).apply { mkdirs() } + val cached = File(dir, "${id}_$size") + if (!cached.exists() || cached.length() == 0L) { + try { + download(id, size, cached) + } catch (e: IOException) { + throw FileNotFoundException("Cover art unavailable: ${e.message}") + } + trimCache(dir) + } + return ParcelFileDescriptor.open(cached, ParcelFileDescriptor.MODE_READ_ONLY) + } + + private fun download(id: String, size: Int, target: File) { + val appContext = requireNotNull(context).applicationContext + val client = EntryPoints.get(appContext, OkHttpClientEntryPoint::class.java).okHttpClient() + val url = requireNotNull(coverArtUrl(id, size)) + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + val body = response.body + // Subsonic reports a missing/invalid cover as a 200 with a JSON error body, so a + // success status alone isn't enough - check it's actually an image. + if (!response.isSuccessful || body.contentType()?.type != "image") { + throw IOException("HTTP ${response.code}, ${body.contentType()}") + } + val partial = File(target.parentFile, "${target.name}.part") + partial.outputStream().use { out -> body.byteStream().copyTo(out) } + if (!partial.renameTo(target)) { + partial.delete() + throw IOException("Could not move cached file into place") + } + } + } + + private fun trimCache(dir: File) { + val files = dir.listFiles()?.filter { it.isFile } ?: return + var total = files.sumOf { it.length() } + for (file in files.sortedBy { it.lastModified() }) { + if (total <= MAX_CACHE_BYTES) break + total -= file.length() + file.delete() + } + } + + override fun getType(uri: Uri): String = "image/*" + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt index 44d54ac..16db5fa 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt @@ -5,6 +5,7 @@ import android.content.Intent import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource @@ -67,8 +68,17 @@ class DeepwavePlaybackService : MediaLibraryService() { PendingIntent.FLAG_IMMUTABLE, ) + // The session's default bitmap loader uses a plain HTTP client, which can't resolve the + // placeholder-host artwork URIs on our MediaItems (only the signed OkHttpClient can). + // Session artwork - the notification, lock screen, and Android Auto's now-playing art - + // goes through this loader, so point it at the same DefaultDataSource as playback. + val bitmapLoader = DataSourceBitmapLoader.Builder(this) + .setDataSourceFactory(defaultDataSourceFactory) + .build() + mediaLibrarySession = MediaLibrarySession.Builder(this, player, browseTree) .setSessionActivity(sessionActivityIntent) + .setBitmapLoader(bitmapLoader) .build() } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt index 858a1bd..935decb 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt @@ -1,11 +1,9 @@ package com.InfernalAquatics.deepwave.media -import android.net.Uri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity import com.InfernalAquatics.deepwave.data.model.Song -import com.InfernalAquatics.deepwave.data.network.coverArtUrl import com.InfernalAquatics.deepwave.data.network.streamUrl private const val MEDIA_ID_PREFIX = "subsonic:song:" @@ -16,13 +14,25 @@ fun songMediaId(songId: String): String = "$MEDIA_ID_PREFIX$songId" fun songIdFromMediaId(mediaId: String): String? = mediaId.removePrefix(MEDIA_ID_PREFIX).takeIf { it != mediaId } +/** + * Artwork is a [coverArtContentUri], not the in-app placeholder-host URL: MediaItem artwork is + * published to other processes (System UI's media controls, Android Auto) that fetch it + * themselves and can't resolve anything only our signed OkHttpClient understands. Coil in the + * phone UI reads the same `content://` URI, so there's a single artwork URI everywhere. + */ fun Song.toMediaItem(): MediaItem { + // isPlayable/isBrowsable are what Media3 turns into Android Auto's FLAG_PLAYABLE/ + // FLAG_BROWSABLE - with neither set, a track shows up in the car as an inert row. val metadata = MediaMetadata.Builder() .setTitle(title) .setArtist(artistName) .setAlbumTitle(albumName) + .setIsPlayable(true) + .setIsBrowsable(false) .apply { - coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) } + track?.let { setTrackNumber(it) } + durationSeconds?.let { setDurationMs(it * 1000L) } + setArtworkUri(coverArtContentUri(coverArtId)) } .build() @@ -44,8 +54,10 @@ fun DownloadedTrackEntity.toMediaItem(): MediaItem { .setTitle(title) .setArtist(artistName) .setAlbumTitle(albumName) + .setIsPlayable(true) + .setIsBrowsable(false) .apply { - coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) } + setArtworkUri(coverArtContentUri(coverArtId)) } .build() diff --git a/app/src/test/java/com/InfernalAquatics/deepwave/media/BrowseTreeHelpersTest.kt b/app/src/test/java/com/InfernalAquatics/deepwave/media/BrowseTreeHelpersTest.kt new file mode 100644 index 0000000..d7886fb --- /dev/null +++ b/app/src/test/java/com/InfernalAquatics/deepwave/media/BrowseTreeHelpersTest.kt @@ -0,0 +1,47 @@ +package com.InfernalAquatics.deepwave.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrowseTreeHelpersTest { + + private val items = (1..10).toList() + + @Test + fun `page returns everything when the browser asks for no paging`() { + assertEquals(items, items.page(page = 0, pageSize = Int.MAX_VALUE)) + assertEquals(items, items.page(page = 0, pageSize = 0)) + } + + @Test + fun `page slices by page index`() { + assertEquals(listOf(1, 2, 3, 4), items.page(page = 0, pageSize = 4)) + assertEquals(listOf(5, 6, 7, 8), items.page(page = 1, pageSize = 4)) + assertEquals(listOf(9, 10), items.page(page = 2, pageSize = 4)) + } + + @Test + fun `page past the end is empty rather than throwing`() { + assertEquals(emptyList(), items.page(page = 5, pageSize = 4)) + assertEquals(emptyList(), items.page(page = Int.MAX_VALUE, pageSize = Int.MAX_VALUE - 1)) + } + + @Test + fun `real Navidrome style cover art ids are accepted`() { + assertTrue(isValidCoverArtId("al-3f2b8c1e-0a4d-4c55-9f0e-5b1c2d3e4f5a_6501a2b3")) + assertTrue(isValidCoverArtId("pl-abc123")) + assertTrue(isValidCoverArtId("12345")) + } + + @Test + fun `cover art ids that could alter the request are rejected`() { + assertFalse(isValidCoverArtId("")) + assertFalse(isValidCoverArtId("a&b=c")) + assertFalse(isValidCoverArtId("a/b")) + assertFalse(isValidCoverArtId("../etc")) + assertFalse(isValidCoverArtId("a b")) + assertFalse(isValidCoverArtId("x".repeat(129))) + } +}