Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
447 lines
20 KiB
Plaintext
447 lines
20 KiB
Plaintext
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<String>()
|
|
|
|
/**
|
|
* 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<LibraryResult<MediaItem>> {
|
|
/* 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<LibraryResult<ImmutableList<MediaItem>>> {
|
|
/* 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<LibraryResult<ImmutableList<MediaItem>>>()
|
|
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<MediaItem>()
|
|
}
|
|
out.set(LibraryResult.ofItemList(items, params))
|
|
}
|
|
return out
|
|
}
|
|
|
|
override fun onGetItem(
|
|
session: MediaLibraryService.MediaLibrarySession,
|
|
browser: MediaSession.ControllerInfo,
|
|
mediaId: String,
|
|
): ListenableFuture<LibraryResult<MediaItem>> {
|
|
/* 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<MediaItem> {
|
|
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<MediaItem>()
|
|
val ids = HashSet<String>()
|
|
val kinds = LinkedHashSet<String>()
|
|
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<MediaItem>) {
|
|
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"
|
|
}
|
|
}
|
|
}
|