android auto: the covers, and the grid a record is owed

Round fifty put the tree in the car with every row a bare line of text. This
gives the rows their sleeves and asks for albums to be drawn as a grid — the
two things the design picked that a tree alone cannot express, and the only
parts of a car screen the app actually controls.

**Artwork goes out as a `content://`, not as bytes.** media3 will carry a cover
as data in the metadata and the legacy stub Android Auto binds turns that into
a bitmap per row — inside a single Binder reply with a one-megabyte ceiling for
the whole list. Two hundred rows of even thumbnailed JPEG is a
`TransactionTooLargeException` on a list that worked yesterday. A URI is a few
dozen bytes and the host fetches only what it draws. The provider stays
`exported="false"`; each browse grants read on its own URIs to the package that
asked, which `onGetChildren` already knows.

Reaching the library needed a new `file_paths.xml` in the res overlay, and it
needs BOTH spellings of one directory. Covers live under the app's DATA dir,
which `files-path` cannot reach — that tag means `getFilesDir()`, the `files/`
subdirectory. `root-path` can, but FileProvider resolves a file through
`getCanonicalPath()` before matching, and for the primary user
`/data/user/0/<pkg>` canonicalises to `/data/data/<pkg>`, so a root scoped to
`/data/user/` never matches on a phone. `/data/user/` still earns its place:
a car's driver profile is user 10 and does not canonicalise away.

The grid is `CONTENT_STYLE_BROWSABLE_HINT` on the parent's `LibraryParams` —
grid for `ss:albums` and for an artist's albums, list for artists and tracks.
Those keys belong to the host rather than to media3, which is why the same
constants serve a phone-projected car and a head unit alike.

**Three failures, one cause: a catch with nothing in it.** `artUri` returned
null on any throw and said nothing, and every row came back coverless with the
database, the res overlay and the APK all checking out — the binary XML was
dumped out of the APK to prove the root was in there. It cost an afternoon and
two wrong diagnoses. One `Log.w` in the catch named both real bugs in a single
run: `JSONObject.optString` returns the four-character string `"null"` for a
JSON null rather than `""`, so the tabs were handing `File("null")` to the
provider (`isNull` is checked first now); and the canonical-path substitution
above, which is visible ONLY in that exception's text — `readlink -f` on the
device does not show it, and believing it cost the first wrong answer. The
warning latches to one line per process, because two hundred identical traces
would bury the tree logging it sits beside.

The prober reports each row's `iconUri` now. A tree walk could say a row
arrived but never whether it would be drawn with anything, which is the one
thing this round changes, and `art:""` against a real `content://` is the whole
difference.

Verified on the Pixel 6a through Android Auto's own binder with the app cold:
both albums come back carrying
`content://com.soulstream.app.fileprovider/ss_library_canonical/...cover.jpg`.
The car screen itself is still the Desktop Head Unit's to show.
This commit is contained in:
KeelrLeek committed 2026-09-15 11:11:35 -05:00
1 parent 515b3da71f
commit 3f56f274c5
4 files changed
+188 -4

No files matched your search

@@ -34,10 +34,12 @@ import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.core.content.FileProvider
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executors
import org.json.JSONArray
@@ -477,6 +479,19 @@ object LibraryTree : MediaLibrarySession.Callback {
* with a media id that happens to be a file path. */
const val ROOT = "ss:root"
/* The content-style extras. These are plain string keys rather than a
constant from a library because they belong to the Auto host, not to
media3 — the same keys the legacy MediaBrowserService protocol has
always used, which is what makes them work for a car and a head unit
alike. 1 is a list, 2 is a grid. */
private const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
private const val CONTENT_STYLE_BROWSABLE_HINT =
"android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
private const val CONTENT_STYLE_PLAYABLE_HINT =
"android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
private const val CONTENT_STYLE_LIST = 1
private const val CONTENT_STYLE_GRID = 2
/**
* IMMEDIATE, UNCONDITIONALLY, AND THAT IS NOT A STYLE CHOICE.
*
@@ -545,13 +560,77 @@ object LibraryTree : MediaLibrarySession.Callback {
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> =
Futures.submit<LibraryResult<ImmutableList<MediaItem>>>(
{
val items = read(parentId)
val items = read(parentId, browser.packageName)
android.util.Log.i("SoulStream", "auto children $parentId n=" + items.size)
LibraryResult.ofItemList(items, params)
LibraryResult.ofItemList(items, styleFor(parentId))
},
io,
)
/**
* How the car should DRAW this node's children — the only say the app has
* over the layout, since the host builds the UI and we supply content.
*
* Kyle's pick: albums are a grid, artists and tracks are lists. It is not
* a density choice — the car fits about three rows either way — it is
* about what identifies the row. A record is known by its sleeve, which is
* this app's whole thesis; an artist is known by a name and a track only
* by text, and those want the larger type a list gives them.
*
* An artist's albums are albums, so they get the grid too.
*/
private fun styleFor(parentId: String): LibraryParams {
val grid = parentId == "ss:albums" || parentId.startsWith("ss:artist:")
val b = android.os.Bundle()
b.putBoolean(CONTENT_STYLE_SUPPORTED, true)
b.putInt(CONTENT_STYLE_BROWSABLE_HINT, if (grid) CONTENT_STYLE_GRID else CONTENT_STYLE_LIST)
b.putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
return LibraryParams.Builder().setExtras(b).build()
}
/**
* A cover as a `content://` the HOST can read.
*
* Not bytes. media3 will carry artwork as data in the metadata, and the
* legacy stub Auto binds turns that into a bitmap per row — which travels
* over a Binder transaction with a one-megabyte ceiling for the WHOLE
* reply. Two hundred rows of even thumbnailed JPEG is a
* TransactionTooLargeException, and the failure lands on a list that was
* fine yesterday. A URI is a few dozen bytes and the host fetches only
* what it draws.
*
* The provider is `exported="false"`, so the URI is useless to anyone
* until it is granted — which is why this takes the package that asked and
* grants read to exactly it, per browse.
*/
private fun artUri(ctx: Context, path: String?, grantTo: String?): android.net.Uri? {
if (path.isNullOrEmpty()) return null
return try {
val uri =
FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path))
if (!grantTo.isNullOrEmpty()) {
ctx.grantUriPermission(grantTo, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
uri
} catch (e: Throwable) {
// A path outside the provider's roots, or a cover that has since
// been deleted. A row without art still draws — but a SILENT
// null here is what made this cost a whole afternoon: every row
// came back with no cover and nothing anywhere said why, so the
// database, the overlay and the APK all got searched in turn.
// Log it once with the path that failed.
if (!artWarned) {
artWarned = true
android.util.Log.w("SoulStream", "auto art failed for " + path + ": " + e)
}
null
}
}
/** One line per process, not one per row — 200 identical stack traces
* would bury the tree logging this exists to sit beside. */
@Volatile private var artWarned = false
/** The media type a host uses to decide what a row IS, taken from the id
* prefix the Rust side minted. */
private fun typeOf(id: String): Int =
@@ -567,7 +646,7 @@ object LibraryTree : MediaLibrarySession.Callback {
* unit handed nothing draws an empty folder while a head unit left
* waiting drops the app.
*/
private fun read(parentId: String): ImmutableList<MediaItem> {
private fun read(parentId: String, grantTo: String?): ImmutableList<MediaItem> {
val ctx = MediaHub.appContext ?: return ImmutableList.of()
val json =
try {
@@ -597,6 +676,14 @@ object LibraryTree : MediaLibrarySession.Callback {
md.setSubtitle(sub)
md.setArtist(sub)
}
// `isNull` FIRST, and it is not belt-and-braces. Android's
// JSONObject.optString returns the four-character string
// "null" for a JSON null rather than "", so `.ifEmpty` lets it
// through and the provider is handed File("null") — which is
// exactly how every row lost its cover while the database, the
// res overlay and the APK all checked out.
val artPath = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
artUri(ctx, artPath, grantTo)?.let { md.setArtworkUri(it) }
out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build())
}
} catch (e: Throwable) {
+52
View File
@@ -0,0 +1,52 @@
android auto: the covers, and the grid a record is owed
Round fifty put the tree in the car with every row a bare line of text. This
gives the rows their sleeves and asks for albums to be drawn as a grid — the
two things the design picked that a tree alone cannot express, and the only
parts of a car screen the app actually controls.
**Artwork goes out as a `content://`, not as bytes.** media3 will carry a cover
as data in the metadata and the legacy stub Android Auto binds turns that into
a bitmap per row — inside a single Binder reply with a one-megabyte ceiling for
the whole list. Two hundred rows of even thumbnailed JPEG is a
`TransactionTooLargeException` on a list that worked yesterday. A URI is a few
dozen bytes and the host fetches only what it draws. The provider stays
`exported="false"`; each browse grants read on its own URIs to the package that
asked, which `onGetChildren` already knows.
Reaching the library needed a new `file_paths.xml` in the res overlay, and it
needs BOTH spellings of one directory. Covers live under the app's DATA dir,
which `files-path` cannot reach — that tag means `getFilesDir()`, the `files/`
subdirectory. `root-path` can, but FileProvider resolves a file through
`getCanonicalPath()` before matching, and for the primary user
`/data/user/0/<pkg>` canonicalises to `/data/data/<pkg>`, so a root scoped to
`/data/user/` never matches on a phone. `/data/user/` still earns its place:
a car's driver profile is user 10 and does not canonicalise away.
The grid is `CONTENT_STYLE_BROWSABLE_HINT` on the parent's `LibraryParams` —
grid for `ss:albums` and for an artist's albums, list for artists and tracks.
Those keys belong to the host rather than to media3, which is why the same
constants serve a phone-projected car and a head unit alike.
**Three failures, one cause: a catch with nothing in it.** `artUri` returned
null on any throw and said nothing, and every row came back coverless with the
database, the res overlay and the APK all checking out — the binary XML was
dumped out of the APK to prove the root was in there. It cost an afternoon and
two wrong diagnoses. One `Log.w` in the catch named both real bugs in a single
run: `JSONObject.optString` returns the four-character string `"null"` for a
JSON null rather than `""`, so the tabs were handing `File("null")` to the
provider (`isNull` is checked first now); and the canonical-path substitution
above, which is visible ONLY in that exception's text — `readlink -f` on the
device does not show it, and believing it cost the first wrong answer. The
warning latches to one line per process, because two hundred identical traces
would bury the tree logging it sits beside.
The prober reports each row's `iconUri` now. A tree walk could say a row
arrived but never whether it would be drawn with anything, which is the one
thing this round changes, and `art:""` against a real `content://` is the whole
difference.
Verified on the Pixel 6a through Android Auto's own binder with the app cold:
both albums come back carrying
`content://com.soulstream.app.fileprovider/ss_library_canonical/...cover.jpg`.
The car screen itself is still the Desktop Head Unit's to show.
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides the file_paths.xml `tauri android init` writes, through the res
overlay build.sh copies over gen/ on every build. The first two entries are
tauri's own and are what the share sheet and the playlist export hand out;
the third is ours.
Cover art lives under the LIBRARY, which sits in the app's DATA directory
(`<dataDir>/Library/...`, per config::download_dir). FileProvider has no tag
for `getDataDir()` — `files-path` means `getFilesDir()`, which is the
`files/` subdirectory and does not contain the library — so the only tag that
can reach it is `root-path`.
It is scoped to `/data/user/` rather than `/` so it cannot name anything
outside app storage, and it covers the secondary users a device can run the
app as. The provider itself is `exported="false"` with
`grantUriPermissions="true"`, so nothing is readable until the browse code
grants a specific URI to the specific package that asked for it — which for
Android Auto is the host, per connection.
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
<root-path name="ss_library" path="/data/user/" />
<!--
BOTH spellings of the same directory are needed, and this one is the one
that actually matches on a phone. FileProvider resolves the file through
`getCanonicalPath()` before it looks for a root, and for the primary user
`/data/user/0/<pkg>` canonicalises to `/data/data/<pkg>` — so a cover at
`/data/user/0/.../cover.jpg` is tested as `/data/data/.../cover.jpg` and
misses a root scoped to `/data/user/`. It fails as
"Failed to find configured root that contains ...", naming the canonical
path, which is the only place the substitution is visible: `readlink -f`
on the device does NOT show it.
`/data/user/` above still earns its place — secondary users (a car's
driver profile is user 10) do not canonicalise away.
-->
<root-path name="ss_library_canonical" path="/data/data/" />
</paths>
@@ -159,7 +159,12 @@ class ProbeActivity : Activity() {
.put("id", c.mediaId ?: "")
.put("title", (c.description.title ?: "").toString())
.put("browsable", c.isBrowsable)
.put("playable", c.isPlayable),
.put("playable", c.isPlayable)
// What the host would actually draw the row
// with. Empty means the row has no cover, and
// a row's art is the one thing a tree walk
// cannot otherwise tell you arrived.
.put("art", c.description.iconUri?.toString() ?: ""),
)
}
say(