package com.soulstream.mediasession import android.content.Context import android.content.Intent import android.net.Uri import android.os.SystemClock import android.util.Log import androidx.annotation.OptIn import androidx.core.content.FileProvider 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 import androidx.media3.session.MediaSession 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.SettableFuture import org.json.JSONArray import org.json.JSONObject import java.io.File import java.util.concurrent.Executors /** * The browse tree, between media3 and the Rust that reads library.db. * * The car asks a `MediaLibrarySession.Callback` for a root and for children; * `src-tauri/src/auto.rs` answers both out of the database, read-only, in a * process that has no Activity, no Tauri and no WebView. This file is the * hinge: it loads the native library, hands the two directories down, turns * the JSON that comes back into `MediaItem`s, mints a `content://` for every * cover and grants the browser permission to read it. * * ## Why `System.loadLibrary` is here * * The only other loader in the app is `com.soulstream.app.Rust`'s static * initialiser, and nothing constructs that without an Activity. A service * started for a browse has none — that is the entire point of the round — so * the first thing that wants the native side has to load it. Loading is * idempotent: with the app already running this is a no-op, and * `JNI_OnLoad` (android_glue.rs) has already published the VM and the * Context either way. * * The symbol lives in `libapp_lib.so`, the APP crate's cdylib — the crate * that has the `library` and `player` dependencies. Names link at runtime, * so this plugin's Rust side needs no dependency on the app crate at all. * * ## Why none of it runs on the calling thread * * media3 dispatches every callback on the session's application handler, * which is the main looper. A SQLite open plus a query there is an ANR in a * moving car, and an "app isn't responding" dialog is not something a driver * can deal with. So [children] answers a `SettableFuture` and the work runs * on one thread of this object's own — media3 is built for exactly that, and * `onGetChildren` returning a pending future is its documented shape. * * ## The failure that has no symptom * * media3 grants no URI permission of its own — there is no * `grantUriPermission` anywhere in `MediaSessionImpl`, * `MediaSessionLegacyStub`, `MediaLibraryServiceLegacyStub` or * `LegacyConversions` at 1.10.1 — and the app's FileProvider is * `android:exported="false"`. Forget [grant] and the car browses perfectly * and shows a grid of grey squares, with nothing in any log and nothing * wrong at build time. It is the single easiest thing in this file to lose. * * Artwork goes across as a URI and NEVER as bytes. * `MediaLibraryServiceLegacyStub` decodes `MediaMetadata.artworkData` into a * Bitmap for every item it converts, and Google's own guidance is blunt * about where that ends: a few hundred bitmaps in one Binder transaction is * over the 1 MB limit and the browse fails outright. */ @OptIn(markerClass = [UnstableApi::class]) object LibraryBridge { /** The root node id. Must match `auto.rs`'s `ROOT`. */ const val ROOT = "ss:root" private const val TAG = "SoulStream" /** How many tabs there are, and therefore the largest root-children * limit that means anything. Google's docs put the default at four and * this app has exactly four. */ private const val TABS = 4 private var loaded = false private var loadError = "" init { try { System.loadLibrary("app_lib") loaded = true } catch (e: Throwable) { /* An empty tree with a reason, not a dead service: the card and the session must still work even if the browse side cannot. */ loadError = e.toString() Log.w(TAG, "auto: libapp_lib.so would not load: $e") } } /** One JSON answer for one node. Declared on the object, so JNI hands * the singleton where a static would hand a class — `auto.rs` ignores * it either way. */ external fun browse(node: String, dataDir: String, cacheDir: String): String /** ONE thread, named, so a browse that blocks blocks only browses. */ private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "ss-auto-browse") } /** What the browser said it could show at the root, latched in * `onGetLibraryRoot` because the legacy path — which is the path Auto * takes — hands `onGetChildren` a NULL `LibraryParams` whenever the * browser subscribed without pagination options, and a * `MediaBrowserCompat.subscribe()` always does. The hints exist only in * the root call. */ @Volatile private var rootLimit = TABS /** `none` until the first cover is minted, then `ok` or `fail` — one * `openFileDescriptor` per process against the first URI there is. It * is the only thing on the device that proves the FileProvider root * actually reaches the library's artwork rather than merely producing a * plausible-looking URI. */ @Volatile private var uriProbe = "none" /** Grants already made, so a re-browse of the same tab is not five * hundred more binder calls. Bounded: a library big enough to overflow * this simply pays for the grants again. */ private val granted = HashSet() /** * The callback [MediaService] hands to `MediaLibrarySession.Builder`. * * An anonymous object rather than a class of its own so the latched * state above is plainly in scope, and so there is one place — this file * — where the whole of the car's view of the library lives. */ fun callback(context: Context): MediaLibraryService.MediaLibrarySession.Callback { val ctx = context.applicationContext return object : MediaLibraryService.MediaLibrarySession.Callback { override fun onGetLibraryRoot( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, params: MediaLibraryService.LibraryParams?, ): ListenableFuture> { /* EXTRAS_KEY_ROOT_CHILDREN_LIMIT is media3's own alias for the legacy BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, and it is the only spelling available to us: the legacy class is @RestrictTo(LIBRARY), and androidx.media is runtime scope only since round forty-eight, so neither androidx.media.utils.MediaConstants nor androidx.media3.session.legacy.MediaConstants is on this module's compile classpath. LegacyConversions carries the browser's whole root-hints Bundle into params.extras, so the int is there to be read. */ val hinted = try { params?.extras?.getInt(MediaConstants.EXTRAS_KEY_ROOT_CHILDREN_LIMIT, 0) ?: 0 } catch (e: Exception) { 0 } rootLimit = if (hinted in 1..TABS) hinted else TABS return Futures.immediateFuture( LibraryResult.ofItem( item(ROOT, "tab", "SoulStream", "", browsable = true, playable = false, art = null, durationMs = null), params, ), ) } override fun onGetChildren( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams?, ): ListenableFuture>> { /* page and pageSize are ignored, and that is correct rather than lazy: the legacy stub calls us with (page = 0, pageSize = Integer.MAX_VALUE) for a browser that sent no pagination option, which Auto never does. The cap that matters is auto.rs's five hundred rows, because the whole answer crosses one Binder transaction. */ val node = if (parentId == ROOT) ROOT + "?limit=" + rootLimit else parentId val out = SettableFuture.create>>() io.execute { val items = try { children(ctx, node, browser.packageName) } catch (e: Throwable) { /* never an error code: an empty list is a library that says it is empty, and an error is a head unit that spins */ Log.w(TAG, "auto: browse of $node failed: $e") ImmutableList.of() } out.set(LibraryResult.ofItemList(items, params)) } return out } override fun onGetItem( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String, ): ListenableFuture> { /* Answered from the GRAMMAR, not the database, and it is not a shortcut. The default onSubscribe implementation calls onGetItem and REJECTS the subscription unless what comes back is a browsable item — so leaving this at its default (ERROR_NOT_SUPPORTED) would refuse every subscribe a browser makes. What it needs is the browsable flag, and the id alone says that. */ if (!mediaId.startsWith("ss:")) { return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)) } val kind = kindOf(mediaId) val leaf = kind == "track" || kind == "plitem" return Futures.immediateFuture( LibraryResult.ofItem( item(mediaId, kind, labelOf(mediaId), "", !leaf, leaf, null, null), null, ), ) } } } /* ── the work, off the main thread ──────────────────────────────── */ private fun children(ctx: Context, node: String, pkg: String?): ImmutableList { val began = SystemClock.elapsedRealtime() /* getDataDir(), not getFilesDir(): paths::app_data is tauri's app_data_dir(), which on Android is PathPlugin.getDataDir -> activity.dataDir. config.json, the Library fallback and the .soulstream folder all hang off that, and none of them is under files/. */ val json = if (!loaded) { "" } else { try { browse(node, ctx.dataDir.absolutePath, ctx.cacheDir.absolutePath) } catch (e: Throwable) { Log.w(TAG, "auto: the bridge threw for $node: $e") "" } } val out = ArrayList() val ids = HashSet() val kinds = LinkedHashSet() var note = if (loaded) "" else "native library: $loadError" var ms = -1 var browsable = 0 var playable = 0 var titled = 0 var arted = 0 var failed = 0 if (json.isNotEmpty()) { try { val answer = JSONObject(json) if (note.isEmpty()) note = answer.optString("note", "") ms = answer.optInt("ms", -1) if (answer.optBoolean("capped", false)) { note = if (note.isEmpty()) "capped" else "$note; capped" } val rows = answer.optJSONArray("items") ?: JSONArray() for (i in 0 until rows.length()) { val row = rows.optJSONObject(i) ?: continue val id = row.optString("id", "") if (id.isEmpty()) continue val kind = row.optString("kind", "?") val title = row.optString("title", "") val isBrowsable = row.optBoolean("browsable", false) val isPlayable = row.optBoolean("playable", false) val path = if (row.isNull("art")) "" else row.optString("art", "") var art: Uri? = null if (path.isNotEmpty()) { art = artUri(ctx, path) if (art == null) { failed++ } else { grant(ctx, pkg, art) arted++ } } val duration = if (row.isNull("duration")) null else row.optLong("duration", 0L) out.add(item(id, kind, title, row.optString("subtitle", ""), isBrowsable, isPlayable, art, duration)) ids.add(id) kinds.add(kind) if (isBrowsable) browsable++ if (isPlayable) playable++ if (title.isNotEmpty()) titled++ } } catch (e: Exception) { note = "json: $e" } } else if (note.isEmpty()) { note = "the bridge answered nothing" } probeUri(ctx, out) /* The one line on the DEVICE that says what the car was handed. Nothing else can be asked: there is no screen, no WebView and no app — the whole point of the round — so logcat is where tests/mobile/smoke.mjs reads this back, one line per node, with `note=` LAST because a sqlite message may hold anything, spaces and `=` included. `ms` is measured inside the Rust call and `elapsed` around the whole hop, so a gap between them is the bridge itself. */ Log.i( TAG, "auto node=" + node + " n=" + out.size + " br=" + browsable + " pl=" + playable + " uniq=" + ids.size + " titled=" + titled + " art=" + arted + " fail=" + failed + " lim=" + rootLimit + " open=" + uriProbe + " ms=" + ms + " hop=" + (SystemClock.elapsedRealtime() - began) + " kinds=" + kinds.joinToString("|") + " note=" + note.replace('\n', ' ').replace('\r', ' '), ) return ImmutableList.copyOf(out) } /* ── items ──────────────────────────────────────────────────────── */ /** * The subtitle travels as `artist`, which looks odd and is deliberate: * with no `displayTitle` set, `LegacyConversions` fills a legacy * description's title/subtitle/description from title, artist, album in * that order — so `artist` IS the second line in the car, and setting * `displayTitle` instead would throw the artist away entirely (which is * what the AVRCP note in media3's own source is about). */ private fun item( id: String, kind: String, title: String, subtitle: String, browsable: Boolean, playable: Boolean, art: Uri?, durationMs: Long?, ): MediaItem { val md = MediaMetadata.Builder() .setTitle(title) /* both, non-null, always: LibraryResult.ofItem and ofItemList verify each of them and throw IllegalArgumentException on a null — which would take the whole browse down */ .setIsBrowsable(browsable) .setIsPlayable(playable) .setMediaType(mediaType(kind, browsable)) if (subtitle.isNotEmpty()) md.setArtist(subtitle) if (art != null) md.setArtworkUri(art) if (durationMs != null && durationMs > 0L) md.setDurationMs(durationMs) return MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build() } private fun mediaType(kind: String, browsable: Boolean): Int = when (kind) { "album" -> MediaMetadata.MEDIA_TYPE_ALBUM "artist" -> MediaMetadata.MEDIA_TYPE_ARTIST "playlist" -> MediaMetadata.MEDIA_TYPE_PLAYLIST "track", "plitem" -> MediaMetadata.MEDIA_TYPE_MUSIC else -> if (browsable) MediaMetadata.MEDIA_TYPE_FOLDER_MIXED else MediaMetadata.MEDIA_TYPE_MUSIC } /** `ss:album:12` -> `album`; the root is its own kind. */ private fun kindOf(id: String): String { if (id == ROOT) return "tab" val rest = id.removePrefix("ss:") val cut = rest.indexOf(':') return if (cut <= 0) rest else rest.substring(0, cut) } private fun labelOf(id: String): String = when (id) { ROOT -> "SoulStream" "ss:tab:recent" -> "Recent" "ss:tab:albums" -> "Albums" "ss:tab:artists" -> "Artists" "ss:tab:playlists" -> "Playlists" else -> id } /* ── artwork ────────────────────────────────────────────────────── */ /** * A file path from the database becomes a `content://` through the app's * own FileProvider, whose roots are `src-tauri/android/res/xml/ * file_paths.xml`. `getUriForFile` throws `IllegalArgumentException` for * a path under no configured root, which is caught here and counted: a * cover that cannot be addressed is a missing cover, never a failed * browse. */ private fun artUri(ctx: Context, path: String): Uri? = try { FileProvider.getUriForFile(ctx, ctx.packageName + ".fileprovider", File(path)) } catch (e: Exception) { Log.w(TAG, "auto: no provider root for $path ($e)") null } /** * The line without which the car shows grey squares and says nothing. * See the class note: media3 grants nothing, the provider is not * exported, and only the browser we are answering may read the cover — * that exact URI, that one package. */ private fun grant(ctx: Context, pkg: String?, uri: Uri) { if (pkg.isNullOrEmpty() || pkg == ctx.packageName) return val key = "$pkg $uri" synchronized(granted) { if (granted.size > 4000) granted.clear() if (!granted.add(key)) return } try { ctx.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (e: Exception) { Log.w(TAG, "auto: could not grant $uri to $pkg: $e") } } /** Once per process, against the first cover there is: does the URI we * just minted actually open? Nothing else proves the provider root * reaches the library rather than merely looking like it might. */ private fun probeUri(ctx: Context, items: List) { if (uriProbe != "none") return val uri = items.firstNotNullOfOrNull { it.mediaMetadata.artworkUri } ?: return uriProbe = try { val fd = ctx.contentResolver.openFileDescriptor(uri, "r") if (fd == null) { "fail" } else { fd.close() "ok" } } catch (e: Exception) { Log.w(TAG, "auto: $uri would not open: $e") "fail" } } }