Files
SoulStream/handoff/rounds/ins-r51-nativeauto.txt
T
KeelrLeek 9f0601027c 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.
2026-09-14 07:57:41 -05:00

168 lines
7.2 KiB
Plaintext

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
}
}