android auto: the car can see the app, and the root it opens is empty
Android Auto reaches a media app through `MediaBrowserService`, so `MediaService` becomes a `MediaLibraryService`, one intent filter carries three actions — media3's `MediaLibraryService` and `MediaSessionService`, plus the legacy `android.media.browse.MediaBrowserService` that Auto actually binds — and `res/xml/automotive_app_desc.xml` with the `com.google.android.gms.car.application` meta-data declares the app to the car. Auto lists SoulStream, opens it, and gets a browsable root. There is nothing under it: `onGetChildren` is left un-overridden so the default answers `ERROR_NOT_SUPPORTED` at once, and round fifty fills the tree. `onGetLibraryRoot` answers out of `Futures.immediateFuture` — no database, no I/O, nothing deferred — because `MediaLibraryServiceLegacyStub.onGetRoot` blocks the binder thread serving the host on that future with NO timeout and runs the callback on the main handler. A root that waits on anything is a head unit that gives up. The round carries a mitigation it cannot be split from. androidx/media #3158 — open since 2026-04-03, confirmed again 2026-09-04, reproduced on media3 1.11.0 — has a host that binds a `MediaLibraryService` with nothing playing drive the service round `onCreate` → `onBind` → `onGetSession` → `onConnect` → `onDestroy` at about thirty cycles a second, deterministically after a force-stop with no existing binding. That is exactly how Auto connects and exactly what the prober does. media3 will not act before an Auto-side fix lands, so the loop cannot be stopped from here; it can only be made to cost nothing. The session, the player, the notification provider, the audio-focus request and the becoming-noisy receiver move into `MediaHub` for the life of the PROCESS and `onCreate` allocates and registers nothing — round forty-eight's version would have built a platform session and a legacy browser stub thirty times a second, registered and unregistered a receiver sixty times a second, and abandoned audio focus thirty times a second while music was playing. Three consequences, each a trap for the next round. `onDestroy` calls `removeSession` and never `release()`: `removeSession` drops the session from that instance's map and clears its listener, which is what stops a destroyed service being called back, while `markReleased()` would latch `released` and leave a process-lifetime player that never updates the card again. The session is built with `MediaLibrarySession.Builder(applicationContext, …)` rather than the `(MediaLibraryService, …)` overload, because `MediaSessionServiceLegacyStub.initialize()` does `attachToBaseContext(sessionImpl.getContext())` — a session built against the Service pins a destroyed Service as the base context of a live `MediaBrowserServiceCompat`. And nothing releases the session any more, so it and its platform `MediaSessionCompat` live until the process dies; the walk's `one-session` check is what keeps that honest. `onCreate` logs `auto service created n=…` and `onDestroy` logs `auto service destroyed`, which are the only way to count the loop on a real phone. And the instrument. `tools/auto-prober/` is a throwaway app that binds the same way Auto does, with Auto's own root hints, walks whatever tree comes back, and logs one JSON object per event under `SoulAutoProbe`. It is framework-only (`android.media.browse.MediaBrowser`, not androidx, not media3) because that is the path Auto itself takes into a media3 app, and a prober built out of the library it is probing would not prove that path works. It lives outside `src-tauri/gen/android/` because `tauri android init` would delete it, and borrows the generated project's Gradle wrapper rather than shipping one, so a run downloads nothing. With SoulStream force-stopped there is no WebView, so no CDP, so `smoke.mjs` is blind to every bit of this — the prober is the only eye the project has on Auto without a head unit. The number it exists for is `root`: milliseconds from `connect()` to `onConnected()` with the process cold. Two corrections to the plan, argued in the files. `adb shell pm dump` is `dumpsys package` and prints NO application meta-data, so the Auto declaration cannot be a hard walk assertion — the two that ARE hard are the intent filters (`auto-browse`, `auto-library`), which catch the `TAURI_ANDROID_PROJECT_PATH` trap completely because the filters and the meta-data live in one library manifest and are lost together; the declaration is read instead by the prober, through `PackageManager.GET_META_DATA` and the app's own `Resources`, and echoed back by the walk as the advisory `auto-desc`. And the prober module compiles at jvmTarget 1.8 like every other module here, not 17 — JDK 17 is what RUNS Gradle. No Rust, no frontend, no cargo. The round was authored where none of it could be compiled and it has now been built and run: the Kotlin compiles, the APK installs, and the prober binds the app cold. `desc` resolves the meta-data to a real `xml/automotive_app_desc`; `filter` shows BOTH the legacy `android.media.browse.MediaBrowserService` and media3's own resolving to `MediaService`; `root` comes back at ~2.2 s with `ss:root` and a session token, of which the callback itself is about nine milliseconds and the rest is the cold process starting; `children` answers `n=-1` with nothing in it, which is the un-overridden default doing its job; and `auto service created n=1 reused=false` — #3158 does not loop here. One fix the first run earned. The prober read the descriptor's `name` out of the android namespace, but Google's `<uses name="media"/>` is a BARE attribute with no namespace, so it came back `[""]` — a real declaration and a dangling resource id reported identically, which is the single thing the event exists to tell apart. It reads the null namespace now and keeps ANDROID_NS as a fallback; the second run says `uses:["media"]`. None of this proves the car screen: the Desktop Head Unit or a real head unit is the only thing that shows what Auto draws, how it groups a tree, or whether a tap plays. This proves the app answers.
This commit is contained in:
1 parent
3a8ccd4640
commit
e1bf456743
16 files changed
+1412
-226
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# the prober is source; its build output and its Gradle state are not
|
||||
build/
|
||||
.gradle/
|
||||
local.properties
|
||||
@@ -0,0 +1,94 @@
|
||||
# The Android Auto prober
|
||||
|
||||
A throwaway app that binds SoulStream's `MediaBrowserService` the way Android
|
||||
Auto does, walks whatever tree comes back, and prints one JSON object per
|
||||
event under the logcat tag `SoulAutoProbe`.
|
||||
|
||||
```bash
|
||||
bash tools/auto-prober/probe.sh # depth 2, the ordinary root
|
||||
bash tools/auto-prober/probe.sh 3 true # depth 3, the RECENT root
|
||||
adb uninstall com.soulstream.autoprober # and it is gone
|
||||
```
|
||||
|
||||
## Why it exists
|
||||
|
||||
Android Auto reaches a media app through `MediaBrowserService`, with the
|
||||
app's process cold — no Activity, no WebView, and therefore no DevTools
|
||||
socket. `tests/mobile/smoke.mjs` drives the phone over CDP, so it cannot see
|
||||
any of this: with SoulStream force-stopped there is nothing for it to attach
|
||||
to. Without a head unit or the Desktop Head Unit, this is the only instrument
|
||||
the project has.
|
||||
|
||||
It is framework-only on purpose — `android.media.browse.MediaBrowser`, not
|
||||
androidx, not media3. That is the path 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 probes would not prove that path works.
|
||||
|
||||
## Where it lives, and why not in the generated project
|
||||
|
||||
`src-tauri/gen/android/` is regenerated by `npx tauri android init` and is
|
||||
gitignored; anything put in there is deleted the next time someone runs it.
|
||||
So the prober is its own Gradle build, tracked, outside it — and it borrows
|
||||
the generated project's wrapper rather than shipping one, which pins Gradle
|
||||
8.14.3, AGP 8.11.0, Kotlin 1.9.25 and JDK 17: exactly what is already in the
|
||||
Gradle cache, so a probe run downloads nothing.
|
||||
|
||||
There is deliberately **no `gradle-wrapper.jar`** in this directory.
|
||||
|
||||
## What it prints
|
||||
|
||||
One line per event, each a JSON object, all under `SoulAutoProbe:I`:
|
||||
|
||||
| `ev` | what it says |
|
||||
| --- | --- |
|
||||
| `start` | the package, component, depth and whether the RECENT root was asked for |
|
||||
| `desc` | the `com.google.android.gms.car.application` meta-data resolved to a resource id, that resource's name, and the `<uses name=…>` inside it |
|
||||
| `filter` | what `queryIntentServices` resolves for the legacy browse action and for media3's own, scoped to SoulStream |
|
||||
| `root` | **the number this exists for**: milliseconds from `connect()` to `onConnected()` with the process cold, the root media id, and whether a session token came with it |
|
||||
| `children` | one per subscription: the parent, how many rows, and each row's id, title, browsable and playable |
|
||||
| `fail` | the bind failed, was suspended, or the 8 s watchdog fired |
|
||||
| `done` | the walk finished: elapsed, nodes, leaves |
|
||||
|
||||
`desc` is the important one for the manifest, because `adb shell pm dump`
|
||||
prints resolver tables and package state and **no application meta-data at
|
||||
all** — the phone walk's `auto-browse` and `auto-library` checks can prove
|
||||
the intent filters and nothing else. This resolves the meta-data int through
|
||||
`PackageManager.GET_META_DATA` and then opens the resource out of the app's
|
||||
own `Resources`, which is the only way to tell a real declaration from a
|
||||
dangling id without unpacking the APK.
|
||||
|
||||
`probe.sh` also prints two lines of SoulStream's own log: `auto root …`, the
|
||||
service's view of the same connection (including which root hints survived
|
||||
the trip), and `auto service created n=…`, which is the androidx/media #3158
|
||||
counter — see below.
|
||||
|
||||
## androidx/media #3158
|
||||
|
||||
Open since 2026-04-03, still open, reproduced on media3 1.11.0: a host that
|
||||
binds a `MediaLibraryService` while nothing is playing can put the service
|
||||
into an `onCreate → onBind → onGetSession → onConnect → onDestroy` loop at
|
||||
about thirty cycles a second. It is 100% reproducible after a force-stop with
|
||||
no existing binding — which is precisely what this prober does, so **expect to
|
||||
see it here first**.
|
||||
|
||||
`MediaHub` holds the session, the player, the notification provider, the
|
||||
audio-focus request and the becoming-noisy receiver for the life of the
|
||||
process, so a cycle allocates and registers nothing and the loop is survivable.
|
||||
The loop itself is Android Auto's to fix and media3 has said it will not act
|
||||
first. `auto service created n=…` is how you find out: one or two is normal, a
|
||||
number climbing while a probe or a car is connected is the bug.
|
||||
|
||||
## What it is not
|
||||
|
||||
It does not prove the car screen. Nothing here can: the Desktop Head Unit or
|
||||
a real head unit is the only thing that shows what Auto actually draws, how
|
||||
it groups a tree, or whether a tap plays. This proves the app answers.
|
||||
|
||||
## Safety
|
||||
|
||||
No permissions beyond `<queries>`, no network, no storage, no transport
|
||||
command. Every exit path disconnects the browser and `onDestroy` disconnects
|
||||
again, so nothing is left bound to SoulStream's service. The only force-stop
|
||||
`probe.sh` issues is of `com.soulstream.app`, which `tests/mobile/smoke.mjs`
|
||||
already does on every phone run.
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Copied in shape from src-tauri/gen/android/build.gradle.kts, versions
|
||||
included: AGP 8.11.0 and Kotlin 1.9.25 are what the app is built with
|
||||
and what is already in Kyle's Gradle cache. buildscript{} rather than
|
||||
plugins{} for the same reason it is that way over there. */
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build, install and run the Android Auto prober, then print what it saw.
|
||||
#
|
||||
# bash tools/auto-prober/probe.sh # depth 2, the ordinary root
|
||||
# bash tools/auto-prober/probe.sh 3 true # depth 3, the RECENT root
|
||||
#
|
||||
# This installs a SECOND app on the phone — com.soulstream.autoprober, about
|
||||
# 50 KB, no permissions, no network, no storage, no UI. `adb uninstall
|
||||
# com.soulstream.autoprober` removes it completely. It never signs in,
|
||||
# downloads, joins a room, writes the config or touches the library; the only
|
||||
# thing it force-stops is SoulStream, which the phone walk already does.
|
||||
#
|
||||
# Gradle comes from the GENERATED project's wrapper on purpose: that pins
|
||||
# Gradle 8.14.3, AGP 8.11.0, Kotlin 1.9.25 and JDK 17, all of which are
|
||||
# already in Kyle's Gradle cache, so this downloads nothing new. There is no
|
||||
# gradle-wrapper.jar in this directory and there should not be one.
|
||||
set -euo pipefail
|
||||
ROOT=/f/Coding/SoulStream
|
||||
cd "$ROOT"
|
||||
source src-tauri/android/env.sh
|
||||
|
||||
DEPTH="${1:-2}"
|
||||
RECENT="${2:-false}"
|
||||
APK=tools/auto-prober/prober/build/outputs/apk/debug/prober-debug.apk
|
||||
GRADLEW="$ROOT/src-tauri/gen/android/gradlew.bat"
|
||||
|
||||
test -f "$GRADLEW" || { echo "no generated Android project — run a normal build first"; exit 1; }
|
||||
|
||||
echo "== this installs com.soulstream.autoprober on the phone (adb uninstall com.soulstream.autoprober removes it)"
|
||||
echo "== gradle $(date +%T)"
|
||||
(
|
||||
cd tools/auto-prober
|
||||
MSYS_NO_PATHCONV=1 "$GRADLEW" :prober:assembleDebug -q 2>&1 | grep -v "^$" | tail -20 || true
|
||||
)
|
||||
test -f "$APK" || { echo "no prober APK — gradle's tail is above"; exit 1; }
|
||||
ls -la "$APK"
|
||||
|
||||
echo "== install $(date +%T)"
|
||||
"$ADB" install -r "$APK" > /dev/null
|
||||
|
||||
echo "== cold: force-stopping SoulStream $(date +%T)"
|
||||
"$ADB" logcat -c
|
||||
"$ADB" shell am force-stop com.soulstream.app
|
||||
"$ADB" shell am start -W -n com.soulstream.autoprober/.ProbeActivity \
|
||||
--ei depth "$DEPTH" --ez recent "$RECENT" > /dev/null
|
||||
sleep 7
|
||||
|
||||
echo
|
||||
echo "== the probe (one JSON object per event)"
|
||||
"$ADB" logcat -d -s SoulAutoProbe:I
|
||||
|
||||
echo
|
||||
echo "== the root, as the service saw it"
|
||||
"$ADB" logcat -d -s SoulStream:I | grep "auto root" | tail -3 || echo " (no root line — the callback was never reached)"
|
||||
|
||||
# androidx/media #3158: a host that binds with nothing playing can put the
|
||||
# service in a create/destroy loop at ~30 Hz. The singleton in MediaHub means
|
||||
# nothing is allocated per cycle, but the loop itself is Android Auto's bug
|
||||
# to fix — so this number is the thing to watch, and it is why those two
|
||||
# lifecycle log lines exist at all. One or two is normal.
|
||||
echo
|
||||
echo "== androidx/media #3158: how many times the service was built"
|
||||
"$ADB" logcat -d -s SoulStream:I | grep "auto service created" | tail -1 \
|
||||
|| echo " (no creation line — the service was never built)"
|
||||
@@ -0,0 +1,34 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.soulstream.autoprober"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.soulstream.autoprober"
|
||||
// 26 and 36 to match the app: package visibility (the <queries>
|
||||
// block in the manifest) only behaves like the real thing at a
|
||||
// targetSdk that has it, and a prober that can see more than
|
||||
// Android Auto can would prove the wrong thing.
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
// NO dependencies, deliberately. android.media.browse.MediaBrowser is the
|
||||
// framework class Android Auto itself uses to reach a media3 app, and a
|
||||
// prober built out of media3 would not prove that path works. The Kotlin
|
||||
// plugin adds its own stdlib and nothing else comes along.
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- WITHOUT THIS THE PROBER MEASURES NOTHING AND LOOKS LIKE IT DID.
|
||||
targetSdk 30+ hides every other package from this one, so
|
||||
queryIntentServices() comes back empty and MediaBrowser.connect()
|
||||
fails with nothing useful in the log — which is indistinguishable
|
||||
from "SoulStream has no browse service", i.e. a false report about
|
||||
the one thing this app exists to measure. media3's own
|
||||
SessionToken.java carries the note "We ask the app to declare the
|
||||
<queries> tags, so it's expected that they are missing".
|
||||
Both forms: the package by name for the bind, the action for the
|
||||
PackageManager questions in ProbeActivity.filters(). -->
|
||||
<queries>
|
||||
<package android:name="com.soulstream.app" />
|
||||
<intent>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="SoulStream Auto prober">
|
||||
<!-- No launcher filter and no UI: it is started by name
|
||||
(`am start -n com.soulstream.autoprober/.ProbeActivity`), which
|
||||
is why it must be exported. noHistory and excludeFromRecents
|
||||
keep it out of the recents list Kyle actually uses. -->
|
||||
<activity
|
||||
android:name=".ProbeActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,292 @@
|
||||
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") {
|
||||
// `name` here carries NO namespace. Google's own
|
||||
// descriptor is `<uses name="media"/>` — a bare
|
||||
// attribute, not `android:name` — so reading it out of
|
||||
// ANDROID_NS returns null and the probe reports [""],
|
||||
// which is indistinguishable from a descriptor that
|
||||
// declares nothing. That is the one thing this event
|
||||
// exists to tell apart from a dangling resource id, so
|
||||
// the null namespace is the real read and ANDROID_NS is
|
||||
// only a fallback for a descriptor written the other way.
|
||||
uses.put(
|
||||
xml.getAttributeValue(null, "name")
|
||||
?: 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* The prober is its own build. No pluginManagement block: the root
|
||||
build.gradle.kts puts AGP and the Kotlin plugin on the buildscript
|
||||
classpath the way src-tauri/gen/android does, so nothing here is
|
||||
resolved from the Gradle Plugin Portal. */
|
||||
rootProject.name = "auto-prober"
|
||||
include(":prober")
|
||||
Reference in new issue
Block a user