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(