Files
SoulStream/handoff/rounds/ins-r49-probe-activity.txt
T
KeelrLeek 9f0601027c 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.
2026-09-14 07:57:41 -05:00

280 lines
12 KiB
Plaintext

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()
}
}