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:
co-authored by
Claude Sonnet 5
parent
49f941b8b9
commit
6579f00866
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user