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,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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user