android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
This commit is contained in:
1 parent
867fd42972
commit
9f0601027c
400 files changed
+26401
-3
No files matched your search
@@ -0,0 +1,446 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user