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.
280 lines
12 KiB
Plaintext
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()
|
|
}
|
|
}
|