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,167 @@
|
||||
package com.soulstream.mediasession
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The door between the Rust engine and this process when there is no
|
||||
* WebView — and the reason a service-started process has a native library
|
||||
* loaded at all.
|
||||
*
|
||||
* `System.loadLibrary` appears in exactly one other place in the whole
|
||||
* mobile stack: wry's generated `com.soulstream.app.Rust`, whose static
|
||||
* initializer runs when the Activity is created. Nothing touches it without
|
||||
* an Activity, so in a process Android Auto started by binding this service
|
||||
* the .so would never be loaded, `JNI_OnLoad` would never run, `ndk-context`
|
||||
* would never be filled and rodio could not open an output stream. This
|
||||
* object's `init` is what changes that, and it is modelled byte for byte on
|
||||
* wry's own file: an `object`, a `loadLibrary` in `init`, and `@JvmStatic
|
||||
* external fun`s — which is what makes the natives static and named
|
||||
* `Java_com_soulstream_mediasession_NativeAuto_<name>`.
|
||||
*
|
||||
* The symbol lives in `libapp_lib.so`, the APP crate, because that is the
|
||||
* crate with the `library` and `player` dependencies. Names link at runtime,
|
||||
* so this module's Rust needs no dependency on the app crate at all.
|
||||
*
|
||||
* A second `loadLibrary("app_lib")` from the Activity path is a no-op — the
|
||||
* VM tracks loaded libraries per class loader and both classes share the
|
||||
* app's — and `JNI_OnLoad` still runs exactly once.
|
||||
*
|
||||
* REACHED BY STRING, from both sides: Rust finds this class with
|
||||
* `FindClass` and its two callbacks with `GetStaticMethodID`, so R8
|
||||
* renaming any of it breaks the car at RUNTIME with nothing in any log.
|
||||
* See consumer-rules.pro.
|
||||
*/
|
||||
object NativeAuto {
|
||||
init {
|
||||
try {
|
||||
System.loadLibrary("app_lib")
|
||||
} catch (e: Throwable) {
|
||||
/* The one failure worth a loud line: nothing below this can work
|
||||
and the car will simply do nothing. */
|
||||
Log.e("SoulStream", "auto native library would not load: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var appCtx: Context? = null
|
||||
|
||||
/** Remember an application context for the callbacks. Called from
|
||||
* `MediaService.onCreate` and from `AutoPlay`, so whichever of the two
|
||||
* the car reaches first has already done it. */
|
||||
fun attach(ctx: Context) {
|
||||
if (appCtx == null) appCtx = ctx.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve media ids against the library and play them. Answers
|
||||
* "headless" (this process is driving), "webview" (the app is up and was
|
||||
* handed the rows), "empty" (nothing resolved).
|
||||
*
|
||||
* `dataDir` is `ctx.dataDir` — NOT `filesDir`, which is one level too
|
||||
* deep and would find no database at all. BLOCKING: it opens SQLite and
|
||||
* starts a decoder, so never call it on the main thread; `AutoPlay` owns
|
||||
* the worker that does.
|
||||
*/
|
||||
@JvmStatic external fun play(dataDir: String, ids: Array<String>, startIndex: Int, startAtMs: Long): String
|
||||
|
||||
/**
|
||||
* A transport button with no WebView to give it to. True means the
|
||||
* headless side took it; false means there is a WebView (or nothing
|
||||
* playing) and `MediaHub.onAction` is the right road.
|
||||
*/
|
||||
@JvmStatic external fun transport(action: String, positionSecs: Double): Boolean
|
||||
|
||||
/**
|
||||
* Rust pushing what is playing. The SAME `NowPlayingArgs` the WebView
|
||||
* pushes, through the SAME `MediaHub.update`, so `SoulStreamPlayer`, the
|
||||
* card, the notification and audio focus all keep working by
|
||||
* construction — there is exactly one writer at a time and nothing in
|
||||
* round forty-eight's file needs a second reading path.
|
||||
*
|
||||
* An EMPTY string means the bar emptied.
|
||||
*
|
||||
* Called from Rust's `player-events` thread, which is a pthread attached
|
||||
* as a daemon — not the main looper. That is fine: `MediaHub.update` is
|
||||
* what `MediaSessionPlugin.update` already calls from a binder thread,
|
||||
* and it posts everything that touches media3 to the main looper itself.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun onHeadlessState(json: String) {
|
||||
val ctx = appCtx ?: return
|
||||
try {
|
||||
if (json.isEmpty()) {
|
||||
MediaHub.clear(ctx)
|
||||
return
|
||||
}
|
||||
MediaHub.update(ctx, parse(JSONObject(json)))
|
||||
} catch (e: Throwable) {
|
||||
Log.w("SoulStream", "auto state would not parse: $e")
|
||||
}
|
||||
}
|
||||
|
||||
/** Rust's only way onto the device's log. `tauri_plugin_log` is
|
||||
* registered inside Tauri's `.setup()`, so in a service-started process
|
||||
* `log::info!` reaches nowhere `adb logcat` can be pointed at.
|
||||
*
|
||||
* The prefix is `auto ` and the tag is `SoulStream`, which is the tag
|
||||
* tests/mobile/smoke.mjs already greps. No line from here may contain
|
||||
* the substring "media window " — `mediaWindow()` filters on exactly
|
||||
* that and would swallow ours into the `queue-window` assertion. */
|
||||
@JvmStatic
|
||||
fun log(msg: String) {
|
||||
Log.i("SoulStream", "auto $msg")
|
||||
}
|
||||
|
||||
/* org.json rather than Jackson: this is the framework's own parser, it
|
||||
is in every process for free, and the plugin's Jackson path belongs to
|
||||
Tauri's @InvokeArg machinery, which does not exist here. Field for
|
||||
field and camelCase, like both hops of the real bridge. */
|
||||
/** A string field, or null. `isNull` FIRST and an empty string as the
|
||||
* fallback, both deliberately: `JSONObject.optString` renders a JSON
|
||||
* `null` as the four-letter string "null" — serde writes `null` for
|
||||
* every `Option::None` on this bridge — and Kotlin has no business
|
||||
* handing a platform-typed null default around. */
|
||||
private fun str(o: JSONObject, key: String): String? {
|
||||
if (o.isNull(key)) return null
|
||||
val v = o.optString(key, "")
|
||||
return if (v.isEmpty()) null else v
|
||||
}
|
||||
|
||||
private fun num(o: JSONObject, key: String): Double? =
|
||||
if (o.isNull(key)) null else o.optDouble(key)
|
||||
|
||||
private fun parse(o: JSONObject): NowPlayingArgs {
|
||||
val np = NowPlayingArgs()
|
||||
np.title = str(o, "title")
|
||||
np.artist = str(o, "artist")
|
||||
np.album = str(o, "album")
|
||||
np.playing = o.optBoolean("playing", false)
|
||||
np.position = o.optDouble("position", 0.0)
|
||||
np.duration = num(o, "duration")
|
||||
np.artKey = str(o, "artKey")
|
||||
np.art = str(o, "art")
|
||||
np.hasNext = o.optBoolean("hasNext", true)
|
||||
np.hasPrev = o.optBoolean("hasPrev", true)
|
||||
np.queueIndex = if (o.isNull("queueIndex")) null else o.optInt("queueIndex")
|
||||
np.queueBase = if (o.isNull("queueBase")) null else o.optInt("queueBase")
|
||||
np.repeat = o.optInt("repeat", 0)
|
||||
val arr = o.optJSONArray("queue")
|
||||
if (arr != null) {
|
||||
val rows = ArrayList<QueueItemArgs>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val q = arr.optJSONObject(i) ?: continue
|
||||
val item = QueueItemArgs()
|
||||
item.id = str(q, "id")
|
||||
item.title = str(q, "title")
|
||||
item.artist = str(q, "artist")
|
||||
item.album = str(q, "album")
|
||||
item.duration = num(q, "duration")
|
||||
item.artKey = str(q, "artKey")
|
||||
rows.add(item)
|
||||
}
|
||||
np.queue = rows
|
||||
}
|
||||
return np
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user