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: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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user