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() 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 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, ) { 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 `` 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() } }