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.
168 lines
7.2 KiB
Plaintext
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
|
|
}
|
|
}
|