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:
KeelrLeek committed 2026-09-14 07:57:41 -05:00
1 parent 867fd42972
commit 9f0601027c
400 files changed
+26401 -3

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
package com.soulstream.autoprober
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.media.browse.MediaBrowser
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import org.xmlpull.v1.XmlPullParser
/**
* The only eye this project has on Android Auto without a car.
*
* Auto talks to a media app through `MediaBrowserService`. With SoulStream
* force-stopped there is no Activity, no WebView and therefore no DevTools
* socket, so `tests/mobile/smoke.mjs` — which drives the app over CDP — is
* blind to every bit of it. This app binds the same way Auto does, with
* Auto's own root hints, walks whatever tree comes back, and prints one JSON
* object per event under a single logcat tag, so the whole thing can be read
* with `adb logcat -d -s SoulAutoProbe:I`.
*
* Framework classes only — `android.media.browse.MediaBrowser`, not
* androidx, not media3. That is deliberate: it is the path Android Auto
* itself takes into a media3 app (media3 serves the legacy
* `android.media.browse.MediaBrowserService` action through
* `MediaSessionService.onBind`), and a prober built out of the same library
* as the thing it is probing would not prove that path works.
*
* It changes nothing on the phone. No permissions, no network, no storage,
* no playback command. It force-stops nothing itself; `probe.sh` does that,
* to SoulStream only, exactly as the phone walk already does. Every exit
* path disconnects, and `onDestroy` disconnects again, so no binding is left
* hanging on Kyle's phone if a step throws.
*
* Run it through `tools/auto-prober/probe.sh`, which builds, installs,
* clears logcat, force-stops SoulStream and starts this with the right
* extras.
*/
class ProbeActivity : Activity() {
companion object {
private const val TAG = "SoulAutoProbe"
private const val PKG = "com.soulstream.app"
private const val SVC = "com.soulstream.mediasession.MediaService"
private const val BROWSE = "android.media.browse.MediaBrowserService"
private const val LIBRARY = "androidx.media3.session.MediaLibraryService"
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/** Longer than Auto's own patience and shorter than a person's. A
* root that has not come back by now is the failure this whole app
* exists to catch. */
private const val WATCHDOG_MS = 8000L
}
private val main = Handler(Looper.getMainLooper())
private var browser: MediaBrowser? = null
private var t0 = 0L
private var depth = 2
/** subscriptions asked for and not yet answered; zero means the walk is
* over. Main thread only — every callback lands there. */
private var pending = 0
private var nodes = 0
private var leaves = 0
private var finished = false
private val subscribed = HashSet<String>()
private val watchdog = Runnable { fail("watchdog after " + WATCHDOG_MS + "ms") }
private fun ms() = System.currentTimeMillis() - t0
private fun say(o: JSONObject) { Log.i(TAG, o.toString()) }
override fun onCreate(saved: Bundle?) {
super.onCreate(saved)
t0 = System.currentTimeMillis()
depth = intent?.getIntExtra("depth", 2) ?: 2
val recent = intent?.getBooleanExtra("recent", false) ?: false
say(
JSONObject().put("ev", "start").put("pkg", PKG).put("svc", SVC)
.put("depth", depth).put("recent", recent),
)
describe()
filters()
/* Auto's own root hints, spelled the way the framework spells them.
media3's LegacyConversions.convertToLibraryParams reads exactly
these four and turns them into the LibraryParams that
LibraryTree.onGetLibraryRoot logs on the far side — so what goes in
here and what comes out of `adb logcat -s SoulStream:I` are two
ends of one wire. */
val hints = Bundle()
hints.putBoolean("android.service.media.extra.RECENT", recent)
hints.putInt("androidx.media.MediaBrowserCompat.Extras.KEY_ROOT_CHILDREN_LIMIT", 4)
hints.putInt(
"androidx.media.MediaBrowserCompat.Extras.KEY_ROOT_CHILDREN_SUPPORTED_FLAGS",
MediaBrowser.MediaItem.FLAG_BROWSABLE or MediaBrowser.MediaItem.FLAG_PLAYABLE,
)
hints.putInt("android.media.extras.MEDIA_ART_SIZE_HINT_PIXELS", 320)
main.postDelayed(watchdog, WATCHDOG_MS)
try {
val b = MediaBrowser(this, ComponentName(PKG, SVC), connection, hints)
browser = b
b.connect()
} catch (e: Exception) {
fail("connect threw: " + e)
}
}
private val connection = object : MediaBrowser.ConnectionCallback() {
/** The number the whole exercise is for: milliseconds from
* `connect()` to here, with the app's process cold. Auto gives a
* media app a few seconds and then drops it. */
override fun onConnected() {
val b = browser ?: return
val root = try { b.root } catch (e: Exception) { "" }
val token = try { b.sessionToken != null } catch (e: Exception) { false }
say(
JSONObject().put("ev", "root").put("ms", ms())
.put("id", root).put("tokenOk", token),
)
if (root.isEmpty()) { fail("no root id"); return }
walk(root, 0)
}
/* the two ways a bind ends badly. "connection failed" with nothing
else in the log is usually package visibility — see <queries> in
the manifest — or a service that answered null from onGetSession. */
override fun onConnectionFailed() { fail("connection failed") }
override fun onConnectionSuspended() { fail("connection suspended") }
}
/** Breadth-ish, depth-limited, and every node subscribed at most once.
* A `MediaBrowserService` may answer a subscription more than once
* (that is how it pushes a changed folder), so both callbacks latch. */
private fun walk(parent: String, level: Int) {
val b = browser ?: return
if (parent.isEmpty() || level > depth) { maybeDone(); return }
if (!subscribed.add(parent)) { maybeDone(); return }
pending += 1
b.subscribe(
parent,
object : MediaBrowser.SubscriptionCallback() {
private var answered = false
override fun onChildrenLoaded(
parentId: String,
children: MutableList<MediaBrowser.MediaItem>,
) {
if (answered) return
answered = true
val arr = JSONArray()
for (c in children) {
arr.put(
JSONObject()
.put("id", c.mediaId ?: "")
.put("title", (c.description.title ?: "").toString())
.put("browsable", c.isBrowsable)
.put("playable", c.isPlayable),
)
}
say(
JSONObject().put("ev", "children").put("parent", parentId)
.put("n", children.size).put("items", arr),
)
nodes += 1
/* descend BEFORE this one is retired, so `pending` never
touches zero while there is more tree to read */
for (c in children) {
if (c.isBrowsable) walk(c.mediaId ?: "", level + 1) else leaves += 1
}
pending -= 1
maybeDone()
}
override fun onError(parentId: String) {
if (answered) return
answered = true
say(
JSONObject().put("ev", "children").put("parent", parentId)
.put("n", -1).put("items", JSONArray()),
)
pending -= 1
maybeDone()
}
},
)
}
/** The app's Auto declaration, read out of the app's OWN resources.
* This is the half `adb shell pm dump` cannot do: `dumpsys package`
* prints resolver tables and package state and no `<meta-data>` at all,
* so the only way to prove `com.google.android.gms.car.application`
* points at a real resource — rather than at a dangling id — is to
* resolve the int and then open it. */
private fun describe() {
val o = JSONObject().put("ev", "desc")
try {
val ai = packageManager.getApplicationInfo(PKG, PackageManager.GET_META_DATA)
val id = ai.metaData?.getInt("com.google.android.gms.car.application", 0) ?: 0
o.put("metaResId", id)
if (id != 0) {
val res = packageManager.getResourcesForApplication(ai)
o.put("resName", res.getResourceName(id))
val uses = JSONArray()
val xml = res.getXml(id)
var ev = xml.eventType
while (ev != XmlPullParser.END_DOCUMENT) {
if (ev == XmlPullParser.START_TAG && xml.name == "uses") {
uses.put(xml.getAttributeValue(ANDROID_NS, "name") ?: "")
}
ev = xml.next()
}
xml.close()
o.put("uses", uses)
}
} catch (e: Exception) {
o.put("err", e.toString())
}
say(o)
}
/** What the PackageManager thinks each of the two actions resolves to,
* scoped to SoulStream. An empty list for `browse` and a present one
* for `media3` is a manifest that landed half; two empty lists with a
* working app is package visibility eating this prober. */
private fun filters() {
val o = JSONObject().put("ev", "filter")
for (pair in listOf(Pair("browse", BROWSE), Pair("media3", LIBRARY))) {
val arr = JSONArray()
try {
for (ri in packageManager.queryIntentServices(Intent(pair.second).setPackage(PKG), 0)) {
arr.put(ri.serviceInfo.packageName + "/" + ri.serviceInfo.name)
}
} catch (e: Exception) {
arr.put("ERR " + e)
}
o.put(pair.first, arr)
}
say(o)
}
private fun maybeDone() {
if (finished || pending > 0) return
say(
JSONObject().put("ev", "done").put("ms", ms())
.put("nodes", nodes).put("leaves", leaves),
)
stop()
}
private fun fail(why: String) {
if (finished) return
say(JSONObject().put("ev", "fail").put("why", why).put("ms", ms()))
stop()
}
/** One way out, and `onDestroy` repeats it. A prober that left a binding
* behind would hold SoulStream's service up for as long as this process
* lived, which is the opposite of what it is for. */
private fun stop() {
if (finished) return
finished = true
main.removeCallbacks(watchdog)
try { browser?.disconnect() } catch (_: Exception) {}
browser = null
finish()
}
override fun onDestroy() {
main.removeCallbacks(watchdog)
try { browser?.disconnect() } catch (_: Exception) {}
browser = null
super.onDestroy()
}
}