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: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
android:name=".MainActivity"
android:exported="true"
@@ -1,34 +1,44 @@
package com.InfernalAquatics.deepwave.media
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionError
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import com.InfernalAquatics.deepwave.data.download.DownloadRepository
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.network.coverArtUrl
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.streamUrl
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
import com.InfernalAquatics.deepwave.data.repository.AlbumListType
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.data.repository.ServerRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "BrowseTree"
/**
* 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
@@ -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<String, List<MediaItem>>()
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
@@ -61,8 +80,10 @@ class BrowseTree @Inject constructor(
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
LibraryResult.ofItemList(ImmutableList.copyOf(children(parentId)), params)
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = 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<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(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
query: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> = scope.future {
): ListenableFuture<LibraryResult<Void>> = 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<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
val results = libraryRepository.search(query)
LibraryResult.ofItemList(ImmutableList.copyOf(results.songs.map { it.toMediaItem() }), params)
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = 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<MediaItem> = 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<MediaItem>, 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<MediaItem> {
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<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(
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 <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
* dependency - [SettableFuture] is already transitively on the classpath via media3-session. */
private fun <T> CoroutineScope.future(block: suspend () -> T): ListenableFuture<T> {
val future = SettableFuture.create<T>()
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 <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.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()
}
@@ -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()
@@ -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)))
}
}