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 <noreply@anthropic.com>
This commit is contained in:
Netherwarlord
2026-09-21 02:21:48 -04:00
co-authored by Claude Sonnet 5
parent 49f941b8b9
commit 6579f00866
7 changed files with 498 additions and 38 deletions
@@ -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<MediaItem> {
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<MediaItem> {
val result: LibraryResult<ImmutableList<MediaItem>> =
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 <T> onMain(block: () -> ListenableFuture<T>): ListenableFuture<T> {
lateinit var future: ListenableFuture<T>
instrumentation.runOnMainSync { future = block() }
return future
}
}
+8
View File
@@ -29,6 +29,14 @@
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" /> android:resource="@xml/automotive_app_desc" />
<!-- Serves cover art to Android Auto, which fetches browse-list artwork from its own
process and so can't use the in-app placeholder-host URLs. Exported on purpose;
see CoverArtProvider for why that's safe (validated ids, image bytes only). -->
<provider
android:name=".media.CoverArtProvider"
android:authorities="${applicationId}.coverart"
android:exported="true" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -1,34 +1,44 @@
package com.InfernalAquatics.deepwave.media package com.InfernalAquatics.deepwave.media
import android.net.Uri import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.session.LibraryResult import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.SessionError
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture import com.google.common.util.concurrent.SettableFuture
import com.InfernalAquatics.deepwave.data.download.DownloadRepository import com.InfernalAquatics.deepwave.data.download.DownloadRepository
import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
import com.InfernalAquatics.deepwave.data.model.Album import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.network.coverArtUrl import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.streamUrl import com.InfernalAquatics.deepwave.data.network.streamUrl
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
import com.InfernalAquatics.deepwave.data.repository.AlbumListType import com.InfernalAquatics.deepwave.data.repository.AlbumListType
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.data.repository.ServerRepository import com.InfernalAquatics.deepwave.data.repository.ServerRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
private const val TAG = "BrowseTree"
/** /**
* Android Auto's (and any other [androidx.media3.session.MediaBrowser]'s) view of the library: * Android Auto's (and any other [androidx.media3.session.MediaBrowser]'s) view of the library:
* root -> [Playlists, Artists, Albums, Downloaded] -> drill-down -> tracks. Built entirely on * root -> [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) 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<String, List<MediaItem>>()
override fun onGetLibraryRoot( override fun onGetLibraryRoot(
session: MediaLibrarySession, session: MediaLibrarySession,
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
@@ -61,8 +80,10 @@ class BrowseTree @Inject constructor(
page: Int, page: Int,
pageSize: Int, pageSize: Int,
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = libraryFuture("getChildren($parentId)") {
LibraryResult.ofItemList(ImmutableList.copyOf(children(parentId)), params) 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) 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<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
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( override fun onSearch(
session: MediaLibrarySession, session: MediaLibrarySession,
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
query: String, query: String,
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> = scope.future { ): ListenableFuture<LibraryResult<Void>> = libraryFuture("search($query)") {
val results = libraryRepository.search(query) val results = libraryRepository.search(query)
session.notifySearchResultChanged(browser, query, results.songs.size, params) session.notifySearchResultChanged(browser, query, results.songs.size, params)
LibraryResult.ofVoid() LibraryResult.ofVoid()
@@ -101,79 +147,153 @@ class BrowseTree @Inject constructor(
page: Int, page: Int,
pageSize: Int, pageSize: Int,
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = libraryFuture("searchResult($query)") {
val results = libraryRepository.search(query) val children = songChildren(libraryRepository.search(query).songs)
LibraryResult.ofItemList(ImmutableList.copyOf(results.songs.map { it.toMediaItem() }), params) lastBrowsedTracks[browser.packageName] = children.items
LibraryResult.ofItemList(ImmutableList.copyOf(children.items.page(page, pageSize)), params)
} }
private suspend fun children(parentId: String): List<MediaItem> = when { /** [items] is what gets shown in the browser; [isTrackList] marks it as playable tracks, which
parentId == ROOT_ID -> rootChildren() * become the browser's [lastBrowsedTracks]. */
parentId == CATEGORY_PLAYLISTS -> playlistRepository.observePlaylists().first().map { playlist -> private class Children(val items: List<MediaItem>, val isTrackList: Boolean = false)
browsableItem("$PREFIX_PLAYLIST${playlist.id}", playlist.name, coverArtUrl(playlist.coverArtId))
} private suspend fun children(parentId: String): Children = when {
parentId == CATEGORY_ARTISTS -> libraryRepository.getArtists().map { artist -> parentId == ROOT_ID -> Children(rootChildren())
browsableItem("$PREFIX_ARTIST${artist.id}", artist.name, coverArtUrl(artist.coverArtId)) parentId == CATEGORY_PLAYLISTS -> Children(
} playlistRepository.observePlaylists().first().map { playlist ->
parentId == CATEGORY_ALBUMS -> libraryRepository.getAlbumList(AlbumListType.NEWEST, size = 50).map(::albumItem) browsableItem(
parentId == CATEGORY_DOWNLOADED -> downloadRepository.downloadedTracks().first() id = "$PREFIX_PLAYLIST${playlist.id}",
.filter { it.status == DownloadStatus.COMPLETE } title = playlist.name,
.map { it.toMediaItem() } 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) -> 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) -> 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) -> parentId.startsWith(PREFIX_ALBUM) ->
libraryRepository.getAlbumDetail(parentId.removePrefix(PREFIX_ALBUM)).second.map { it.toMediaItem() } songChildren(libraryRepository.getAlbumDetail(parentId.removePrefix(PREFIX_ALBUM)).second)
else -> emptyList() else -> Children(emptyList())
} }
private suspend fun rootChildren(): List<MediaItem> { private suspend fun rootChildren(): List<MediaItem> {
if (!serverRepository.isLoggedIn.first()) { 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( return listOf(
browsableItem(CATEGORY_PLAYLISTS, "Playlists"), browsableItem(CATEGORY_PLAYLISTS, "Playlists", childrenAsGrid = true),
browsableItem(CATEGORY_ARTISTS, "Artists"), browsableItem(CATEGORY_ARTISTS, "Artists", childrenAsGrid = true),
browsableItem(CATEGORY_ALBUMS, "Albums"), browsableItem(CATEGORY_ALBUMS, "Albums", childrenAsGrid = true),
browsableItem(CATEGORY_DOWNLOADED, "Downloaded"), browsableItem(CATEGORY_DOWNLOADED, "Downloaded"),
) )
} }
private fun songChildren(songs: List<Song>) = Children(songs.map { it.toMediaItem() }, isTrackList = true)
private fun downloadChildren(tracks: List<DownloadedTrackEntity>) =
Children(tracks.map { it.toMediaItem() }, isTrackList = true)
private fun albumItem(album: Album) = browsableItem( private fun albumItem(album: Album) = browsableItem(
id = "$PREFIX_ALBUM${album.id}", id = "$PREFIX_ALBUM${album.id}",
title = album.name, title = album.name,
artworkUrl = coverArtUrl(album.coverArtId), artworkUri = coverArtContentUri(album.coverArtId),
subtitle = album.artistName, subtitle = album.artistName,
) )
private fun browsableItem( private fun browsableItem(
id: String, id: String,
title: String, title: String,
artworkUrl: String? = null, artworkUri: Uri? = null,
subtitle: String? = null, subtitle: String? = null,
playable: Boolean = false, childrenAsGrid: Boolean = false,
): MediaItem { ): MediaItem {
val metadata = MediaMetadata.Builder() val metadata = MediaMetadata.Builder()
.setTitle(title) .setTitle(title)
.setArtist(subtitle) .setArtist(subtitle)
.setIsBrowsable(!playable) .setIsBrowsable(true)
.setIsPlayable(playable) .setIsPlayable(false)
.apply { artworkUrl?.let { setArtworkUri(Uri.parse(it)) } } .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() .build()
return MediaItem.Builder().setMediaId(id).setMediaMetadata(metadata).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 <T : Any> libraryFuture(
what: String,
block: suspend () -> LibraryResult<T>,
): ListenableFuture<LibraryResult<T>> = scope.future<LibraryResult<T>> {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "$what failed", e)
LibraryResult.ofError<T>(SessionError.ERROR_IO)
}
}
/** Bridges a suspend block to the [ListenableFuture] Media3's callbacks require, with no new /** 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. */ * dependency - [SettableFuture] is already transitively on the classpath via media3-session. */
private fun <T> CoroutineScope.future(block: suspend () -> T): ListenableFuture<T> { private fun <T> CoroutineScope.future(block: suspend () -> T): ListenableFuture<T> {
val future = SettableFuture.create<T>() val future = SettableFuture.create<T>()
launch { val job = launch {
try { try {
future.set(block()) future.set(block())
} catch (e: CancellationException) {
future.cancel(false)
throw e
} catch (e: Exception) { } catch (e: Exception) {
future.setException(e) 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 return future
} }
@@ -189,3 +309,12 @@ class BrowseTree @Inject constructor(
const val PREFIX_ALBUM = "album:" 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 <T> List<T>.page(page: Int, pageSize: Int): List<T> {
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())
}
@@ -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-<uuid>_<hex>`; 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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
}
@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -67,8 +68,17 @@ class DeepwavePlaybackService : MediaLibraryService() {
PendingIntent.FLAG_IMMUTABLE, 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) mediaLibrarySession = MediaLibrarySession.Builder(this, player, browseTree)
.setSessionActivity(sessionActivityIntent) .setSessionActivity(sessionActivityIntent)
.setBitmapLoader(bitmapLoader)
.build() .build()
} }
@@ -1,11 +1,9 @@
package com.InfernalAquatics.deepwave.media package com.InfernalAquatics.deepwave.media
import android.net.Uri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
import com.InfernalAquatics.deepwave.data.model.Song import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.data.network.streamUrl import com.InfernalAquatics.deepwave.data.network.streamUrl
private const val MEDIA_ID_PREFIX = "subsonic:song:" 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? = fun songIdFromMediaId(mediaId: String): String? =
mediaId.removePrefix(MEDIA_ID_PREFIX).takeIf { it != mediaId } 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 { 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() val metadata = MediaMetadata.Builder()
.setTitle(title) .setTitle(title)
.setArtist(artistName) .setArtist(artistName)
.setAlbumTitle(albumName) .setAlbumTitle(albumName)
.setIsPlayable(true)
.setIsBrowsable(false)
.apply { .apply {
coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) } track?.let { setTrackNumber(it) }
durationSeconds?.let { setDurationMs(it * 1000L) }
setArtworkUri(coverArtContentUri(coverArtId))
} }
.build() .build()
@@ -44,8 +54,10 @@ fun DownloadedTrackEntity.toMediaItem(): MediaItem {
.setTitle(title) .setTitle(title)
.setArtist(artistName) .setArtist(artistName)
.setAlbumTitle(albumName) .setAlbumTitle(albumName)
.setIsPlayable(true)
.setIsBrowsable(false)
.apply { .apply {
coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) } setArtworkUri(coverArtContentUri(coverArtId))
} }
.build() .build()
@@ -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<Int>(), items.page(page = 5, pageSize = 4))
assertEquals(emptyList<Int>(), 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)))
}
}