android auto: a tap in the car plays, with no Activity anywhere in the process
Android Auto starts this app by binding a service. There is no Activity, no WebView, no `tauri::AppHandle` — and until now no way to make a sound, because the engine lived in Tauri's managed state. `src-tauri/src/headless.rs` moves it into a `OnceLock` owned by the PROCESS: whoever needs audio first builds it, `playback::init` on a normal launch or a car tapping a row into a process with no UI, and everyone after that gets the same one. **That singleton is the whole safety story.** `Player::spawn` opens an output stream, so two of them in one process is two AAudio streams playing over each other — the failure this module exists to make impossible. The event receiver is single-consumer, so the pump lives there too and offers a subscription slot for the WebView to claim when it finally appears, rather than a second engine being stood up beside the first. The queue here is deliberately not the app's queue. The JS one keeps shuffle, repeat, `alts` failover, provenance and the three-ahead preload; this is a vector and an index whose only job is that the album keeps playing when there is no JS alive to advance it. Its metadata is read ONCE, when the queue is built, so advancing never touches the database again — which matters when the thing advancing is a background thread in a process with no UI. Resolution happens in Rust, against the library, read-only. A tapped `ss:track:<id>` brings its whole album at the right index, because selecting one song and getting silence after it is not what a record does. Nothing trusts a URI from the controller: the tree only ever publishes `ss:` ids, so there is no file path for anyone to hand back, and an id that resolves to nothing returns a count of zero rather than a guess. Kotlin's half is three things that each fail invisibly on their own. `COMMAND_SET_MEDIA_ITEM` is advertised now — without it a browse row is drawn as playable and does nothing when tapped. `onAddMediaItems` passes ids through instead of media3's default `UnsupportedOperationException`, which kills the tap before it reaches the player. And `handleSetMediaItems` does the work on its own thread, because the first tap in a cold process opens a database AND starts an audio engine, and it is called on the application thread where ten seconds is an ANR. **Two things the device taught, both of which looked like silence.** The headless path has to request audio focus itself: the first run showed a live AAudio track on our uid, decoding perfectly, with `PortMuted=true` and the gain at `-inf` — audible to nobody. On the WebView path the JS asks for focus; a car tapping a cold process has no JS to ask. And it has to write the card itself: `getState()` mirrors `MediaHub.state`, which only the WebView ever fills, so the second run played audio while the session still reported `STATE_NONE` with no title — a head unit showing a blank player over music. The engine knows what it started, and that now goes into `MediaHub.update` in the shape the card already reads. Proven by the prober, which gained a `play` mode for it: bind cold, issue `playFromMediaId` through the session token the browser handed back — Auto's own path, not an interface of ours — and read the state back. `ss:track:1` answers `queued=13` (the album behind it), and the controller reports `state=3` with the title and a position moving 3.8 s → 11.8 s → 19.8 s across the run. `probe.sh <depth> <recent> <mediaId>` is the invocation. **Not done, and it is the display rather than the audio.** Nothing tells Kotlin when the fallback queue advances, so the card will keep showing track one while track two plays. The position moves on its own — `SimpleBasePlayer` extrapolates it while playing — but a track change and a seek both need the pump to push, and the pump currently only speaks to a subscriber that exists when there is a WebView. The handover the design asks for (a UI arriving over live headless playback, and exactly one queue driver surviving) is untested for the same reason.
This commit is contained in:
1 parent
3f56f274c5
commit
f50cf2018a
8 files changed
+527
-6
No files matched your search
@@ -21,6 +21,10 @@ source src-tauri/android/env.sh
|
||||
|
||||
DEPTH="${1:-2}"
|
||||
RECENT="${2:-false}"
|
||||
# A media id here plays it instead of walking the tree — the only way to prove
|
||||
# a car's tap without a car. The run takes ~25s because the claim being tested
|
||||
# is that the SECOND track starts on its own.
|
||||
PLAY="${3:-}"
|
||||
APK=tools/auto-prober/prober/build/outputs/apk/debug/prober-debug.apk
|
||||
GRADLEW="$ROOT/src-tauri/gen/android/gradlew.bat"
|
||||
|
||||
@@ -42,8 +46,8 @@ 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
|
||||
--ei depth "$DEPTH" --ez recent "$RECENT" --es play "$PLAY" > /dev/null
|
||||
if [ -n "$PLAY" ]; then sleep 25; else sleep 7; fi
|
||||
|
||||
echo
|
||||
echo "== the probe (one JSON object per event)"
|
||||
|
||||
@@ -60,6 +60,8 @@ class ProbeActivity : Activity() {
|
||||
private var browser: MediaBrowser? = null
|
||||
private var t0 = 0L
|
||||
private var depth = 2
|
||||
/** A media id to play instead of walking the tree. Empty = walk. */
|
||||
private var playId = ""
|
||||
/** subscriptions asked for and not yet answered; zero means the walk is
|
||||
* over. Main thread only — every callback lands there. */
|
||||
private var pending = 0
|
||||
@@ -76,6 +78,7 @@ class ProbeActivity : Activity() {
|
||||
super.onCreate(saved)
|
||||
t0 = System.currentTimeMillis()
|
||||
depth = intent?.getIntExtra("depth", 2) ?: 2
|
||||
playId = intent?.getStringExtra("play") ?: ""
|
||||
val recent = intent?.getBooleanExtra("recent", false) ?: false
|
||||
|
||||
say(
|
||||
@@ -123,9 +126,51 @@ class ProbeActivity : Activity() {
|
||||
.put("id", root).put("tokenOk", token),
|
||||
)
|
||||
if (root.isEmpty()) { fail("no root id"); return }
|
||||
if (playId.isNotEmpty()) { play(b); return }
|
||||
walk(root, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* `playFromMediaId`, the way a car plays a browse row, and the only
|
||||
* way to prove the tap works without a head unit.
|
||||
*
|
||||
* It goes through the session token the browser handed back rather
|
||||
* than through any interface of ours: that is the path Android Auto
|
||||
* takes, so a shortcut here would prove a path nothing uses. The
|
||||
* state is read back twice — once shortly after, once late enough for
|
||||
* the NEXT track — because the interesting claim is not that a file
|
||||
* played but that the queue advanced with no JS alive to advance it.
|
||||
*/
|
||||
private fun play(b: android.media.browse.MediaBrowser) {
|
||||
val ctl = try {
|
||||
android.media.session.MediaController(this@ProbeActivity, b.sessionToken)
|
||||
} catch (e: Exception) {
|
||||
fail("no controller: $e"); return
|
||||
}
|
||||
// The bind watchdog has done its job by now, and the whole point
|
||||
// of this path is to outlive it — a second track does not start
|
||||
// inside eight seconds.
|
||||
main.removeCallbacks(watchdog)
|
||||
say(JSONObject().put("ev", "play").put("id", playId).put("ms", ms()))
|
||||
ctl.transportControls.playFromMediaId(playId, null)
|
||||
val report = { tag: String ->
|
||||
val st = ctl.playbackState
|
||||
val md = ctl.metadata
|
||||
say(
|
||||
JSONObject().put("ev", tag).put("ms", ms())
|
||||
.put("state", st?.state ?: -1)
|
||||
.put("pos", st?.position ?: -1)
|
||||
.put(
|
||||
"title",
|
||||
md?.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
main.postDelayed({ report("playing") }, 4000)
|
||||
main.postDelayed({ report("still") }, 12000)
|
||||
main.postDelayed({ report("advanced"); stop() }, 20000)
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
Reference in new issue
Block a user