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_`. * * 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, 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(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 } }