From 3f56f274c55758bd040ab08586c334e0746aa65a Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:11:35 -0500 Subject: [PATCH] android auto: the covers, and the grid a record is owed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` canonicalises to `/data/data/`, 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. --- .../soulstream/mediasession/MediaService.kt | 93 ++++++++++++++++++- handoff/rounds/commit-msg50b.txt | 52 +++++++++++ src-tauri/android/res/xml/file_paths.xml | 40 ++++++++ .../soulstream/autoprober/ProbeActivity.kt | 7 +- 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 handoff/rounds/commit-msg50b.txt create mode 100644 src-tauri/android/res/xml/file_paths.xml diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt index b432723..7ba05e5 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -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>> = Futures.submit>>( { - 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 { + private fun read(parentId: String, grantTo: String?): ImmutableList { 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) { diff --git a/handoff/rounds/commit-msg50b.txt b/handoff/rounds/commit-msg50b.txt new file mode 100644 index 0000000..3fd0201 --- /dev/null +++ b/handoff/rounds/commit-msg50b.txt @@ -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/` canonicalises to `/data/data/`, 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. diff --git a/src-tauri/android/res/xml/file_paths.xml b/src-tauri/android/res/xml/file_paths.xml new file mode 100644 index 0000000..6c10ffa --- /dev/null +++ b/src-tauri/android/res/xml/file_paths.xml @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt b/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt index 105f004..abfd86d 100644 --- a/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt +++ b/tools/auto-prober/prober/src/main/java/com/soulstream/autoprober/ProbeActivity.kt @@ -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(