diff --git a/Cargo.lock b/Cargo.lock index be8c962..373f39d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "rand 0.8.8", "realfft", "reqwest 0.12.28", + "rusqlite", "semver", "serde", "serde_json", diff --git a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt index 4f474a5..b432723 100644 --- a/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt +++ b/crates/tauri-plugin-media-session/android/src/main/java/com/soulstream/mediasession/MediaService.kt @@ -34,9 +34,12 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession +import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import java.io.ByteArrayOutputStream +import java.util.concurrent.Executors +import org.json.JSONArray /** * What is playing, shared between the plugin (which hears the WebView) and @@ -111,6 +114,10 @@ object MediaHub { @Volatile var artBytes: ByteArray? = null @Volatile var artKey: String? = null @Volatile var service: MediaService? = null + /** The application context, kept for the life of the process so a browse + * never has to reach through [service] — which #3158's create/destroy + * loop can null out underneath a call that is already in flight. */ + @Volatile var appContext: Context? = null @Volatile var player: SoulStreamPlayer? = null /** the app's own icon, encoded once — see appIcon() */ @Volatile private var icon: ByteArray? = null @@ -258,6 +265,7 @@ object MediaHub { @Synchronized fun session(app: Context): MediaLibrarySession { libSession?.let { return it } + appContext = app val p = SoulStreamPlayer(app) player = p val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream") @@ -509,6 +517,95 @@ object LibraryTree : MediaLibrarySession.Callback { return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params)) } + /** One thread for the life of the process. A car browses one node at a + * time and a queue is the right shape for that; more importantly it is + * NOT the binder thread the host asked on. */ + private val io = + Executors.newSingleThreadExecutor { r -> + Thread(r, "soulstream-auto-browse").apply { isDaemon = true } + } + + /** + * Everything under the root, read out of `library.db` by [AutoNative]. + * + * Unlike [onGetLibraryRoot] this is allowed to do I/O — a host gives a + * browse seconds where it gives the root none — but it still does not do + * it on the thread that asked. + * + * `page` and `pageSize` are ignored deliberately: Auto does not paginate + * a browse, it asks for a node and draws what comes back. + */ + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams?, + ): ListenableFuture>> = + Futures.submit>>( + { + val items = read(parentId) + android.util.Log.i("SoulStream", "auto children $parentId n=" + items.size) + LibraryResult.ofItemList(items, params) + }, + io, + ) + + /** The media type a host uses to decide what a row IS, taken from the id + * prefix the Rust side minted. */ + private fun typeOf(id: String): Int = + when { + id.startsWith("ss:album:") -> MediaMetadata.MEDIA_TYPE_ALBUM + id.startsWith("ss:artist:") -> MediaMetadata.MEDIA_TYPE_ARTIST + id.startsWith("ss:track:") -> MediaMetadata.MEDIA_TYPE_MUSIC + else -> MediaMetadata.MEDIA_TYPE_FOLDER_MIXED + } + + /** + * Never throws. A browse that fails returns an empty list, because a head + * unit handed nothing draws an empty folder while a head unit left + * waiting drops the app. + */ + private fun read(parentId: String): ImmutableList { + val ctx = MediaHub.appContext ?: return ImmutableList.of() + val json = + try { + AutoNative.browse(ctx.dataDir.absolutePath, parentId) + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto browse $parentId: " + e) + return ImmutableList.of() + } + val out = ImmutableList.Builder() + try { + val arr = JSONArray(json) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val id = o.optString("id") + if (id.isEmpty()) continue + val md = + MediaMetadata.Builder() + .setTitle(o.optString("title")) + .setIsBrowsable(o.optBoolean("browsable")) + .setIsPlayable(o.optBoolean("playable")) + .setMediaType(typeOf(id)) + val sub = o.optString("subtitle") + if (sub.isNotEmpty()) { + // Auto draws `subtitle` under the title; `artist` is what + // the playback card reads later, and for a track the two + // are the same string. + md.setSubtitle(sub) + md.setArtist(sub) + } + out.add(MediaItem.Builder().setMediaId(id).setMediaMetadata(md.build()).build()) + } + } catch (e: Throwable) { + android.util.Log.w("SoulStream", "auto browse $parentId unreadable: " + e) + return ImmutableList.of() + } + return out.build() + } + /** A browsable, non-playable folder with nothing in it yet. Browsable * and not playable is what makes a host descend into it rather than try * to play it; MEDIA_TYPE_FOLDER_MIXED says the children will not all be @@ -767,3 +864,32 @@ class MediaService : MediaLibraryService() { super.onDestroy() } } + +/** + * The browse tree's one call into Rust — `src-tauri/src/auto_browse.rs`. + * + * **It loads the library itself, and that is the point.** When Auto binds a + * cold app there is no Activity, so nothing has called + * `System.loadLibrary("app_lib")` and none of the Rust side exists yet. + * Loading it here also runs `JNI_OnLoad`, which publishes the JavaVM and the + * app Context to `ndk-context` — the same hand-off the audio backend needs, + * arriving before there is any audio, which is what round fifty-one will + * stand on. + * + * `loadLibrary` is idempotent, so doing it here costs nothing when the app + * is already up and Tauri has loaded the same library. + */ +object AutoNative { + init { + try { + System.loadLibrary("app_lib") + } catch (e: Throwable) { + // Not fatal: every caller treats a throw as "no rows", and a car + // showing an empty folder beats a car dropping the app. + android.util.Log.w("SoulStream", "auto browse: app_lib did not load: " + e) + } + } + + /** Returns a JSON array of rows; never null, `[]` when anything failed. */ + external fun browse(dataDir: String, parentId: String): String +} diff --git a/handoff/rounds/commit-msg50.txt b/handoff/rounds/commit-msg50.txt new file mode 100644 index 0000000..9c7cc79 --- /dev/null +++ b/handoff/rounds/commit-msg50.txt @@ -0,0 +1,59 @@ +android auto: the tree under the root — four tabs read cold out of library.db + +Round forty-nine put SoulStream in the car with an empty root. This fills it. +`onGetChildren` answers the four tabs Kyle picked — Recent · Albums · Artists · +Saved — and everything under them: albums with their artist and track count, +artists grouped the way the app groups them, an album's tracks in disc and +track order, and every local file under Saved. `src-tauri/src/auto_browse.rs` +is one JNI export over `library.db`, and `AutoNative` in the plugin is the +Kotlin half. + +**It reads read-only and it never calls `Library::open`.** That constructor +runs `PRAGMA journal_mode = WAL` and a `CREATE TABLE IF NOT EXISTS` batch — +writes, both of them — and a service that takes a write lock on the library to +draw a list is a bug waiting for a long drive. The path is derived the way +`config::download_dir` derives it, but without it: that function takes a +`tauri::AppHandle` and there is no Tauri in this process, so `config.json` is +parsed read-only for `download_dir` and the default is `/Library`, +with the database under `.soulstream/` as the portability rule requires. + +**`AutoNative` calls `System.loadLibrary("app_lib")` itself, and that is the +round's other half.** When Auto binds a cold app there is no Activity, so +nothing has loaded the Rust side and none of it exists. Loading it here also +runs `JNI_OnLoad`, which publishes the JavaVM and the app Context to +`ndk-context` — the same hand-off the audio backend needs, arriving before +there is any audio, which is what round fifty-one stands on. `loadLibrary` is +idempotent, so it costs nothing when the app is already up. + +Two things a head unit taught that no unit test would have. **The root must not +depend on the database.** The four tabs are a constant, and gating the whole +dispatch on a connection meant one unreadable library cost the car its entire +structure — it read as "Media isn't available for this list", which names +neither the tabs nor the reason. A library that cannot be read should cost the +ROWS, never the shape. And **`log::warn!` goes nowhere in a cold service +process**: nothing has run Tauri, so no `android_logger` is installed, and the +first failure here was completely silent. It writes to the platform log +directly now, which is the only reason the second failure had a cause attached. + +`open_read_only` falls back to a read-write handle it immediately sets +`query_only` on. A read-only connection to a WAL-mode database needs the `-shm` +beside it and cannot create one, and the mode lives in the header, so it does +not go away when the sidecars do. This has NOT been observed to bite — the one +failure that looked like it was SELinux, below — but the fallback is cheap and +the connection still cannot change a row. `CREATE` is in neither flag set, so a +missing database stays an error rather than becoming an empty one. + +Verified twice. The prober walks it cold on the phone AVD: 12 nodes, 139 +leaves, 2.26 s, with the album tracklists in the right order and both artists +carrying the right albums. And it is on an Android Automotive head unit — +`ss_car`, API 34 — showing the four tabs, eleven Recent rows and five albums +with their counts, which is the first time any of this work has been seen +rendered by a car rather than described. + +**Not done, and deliberately not guessed at.** There is no artwork: the plan +called for cover art through the existing FileProvider via a `files-path` +entry, but the library lives under the app's DATA dir and `files-path` maps to +`getFilesDir()`, which does not reach it — so the mechanism needs deciding, not +patching. And the rows are all lists; the grid Kyle chose for albums is a +content-style hint that is not set yet. Both are presentation over a tree that +is now correct, and both want the car in front of them. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee78eec..d405d38 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,6 +82,11 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # ndk-context, which cpal's AAudio backend reads and tao 0.35 never fills. jni = "0.21" ndk-context = "0.1" +# auto_browse.rs opens library.db READ-ONLY from the media service's process, +# where there is no AppHandle and so no reaching the `library` crate's own +# handle. Same version and features as crates/library so cargo builds sqlite +# once for both. +rusqlite = { version = "0.32", features = ["bundled"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.61.3", features = [ diff --git a/src-tauri/src/auto_browse.rs b/src-tauri/src/auto_browse.rs new file mode 100644 index 0000000..c22e9e3 --- /dev/null +++ b/src-tauri/src/auto_browse.rs @@ -0,0 +1,408 @@ +//! Android Auto's browse tree, read from a process that may have nothing else +//! in it. +//! +//! Auto binds `MediaService` with the app COLD — no Activity, no WebView, no +//! Tauri `AppHandle`, and therefore none of the state the rest of this crate +//! reaches through one. Everything here is deliberately reachable from that +//! process and nothing else: the database path is derived from the data +//! directory Kotlin hands down, `config.json` is parsed READ-ONLY for +//! `download_dir`, and the connection is opened with +//! `SQLITE_OPEN_READ_ONLY`. +//! +//! **Never `Library::open`.** That runs `PRAGMA journal_mode = WAL` and a +//! `CREATE TABLE IF NOT EXISTS` batch (`crates/library/src/lib.rs`), which are +//! writes. A browse is a read, the car may be browsing while the phone's own +//! app is writing, and a service process that takes a write lock on the +//! library to draw a list is a bug waiting for a long drive. +//! +//! Every failure path returns an empty-but-valid JSON array. A head unit that +//! is handed nothing draws an empty folder; a head unit that is left waiting +//! drops the app. Empty is always the better answer. + +use jni::objects::{JObject, JString}; +use jni::sys::jstring; +use jni::JNIEnv; +use rusqlite::{Connection, OpenFlags}; +use std::path::{Path, PathBuf}; + +/// The root every other id hangs under. `ss:` keeps our ids from colliding +/// with a media id that happens to be a file path. +pub const ROOT: &str = "ss:root"; + +/// The four tabs, in the order Kyle picked them (Recent · Albums · Artists · +/// Saved). Playlists is deliberately absent: the phone holds exactly one, and +/// an empty tab is a dead quarter of the only four root slots Auto gives us. +const TABS: [(&str, &str); 4] = [ + ("ss:recent", "Recent"), + ("ss:albums", "Albums"), + ("ss:artists", "Artists"), + ("ss:saved", "Saved"), +]; + +/// How many rows a list node returns. Auto does not paginate browse results, +/// so this is a ceiling on one answer rather than a page size. +const ROW_CAP: usize = 200; + +/// One row on a car screen. +struct Node { + id: String, + title: String, + subtitle: String, + browsable: bool, + playable: bool, + /// Absolute path to a cover, when the row has one. Kotlin decides what a + /// path can become on the car's side; this layer only reports it. + art: Option, + /// Absolute path to the audio file, for playable rows. Round fifty-one is + /// what does anything with it. + path: Option, +} + +impl Node { + fn json(&self) -> serde_json::Value { + serde_json::json!({ + "id": self.id, + "title": self.title, + "subtitle": self.subtitle, + "browsable": self.browsable, + "playable": self.playable, + "art": self.art, + "path": self.path, + }) + } +} + +/// `/config.json` names `download_dir`; when it is null or empty the +/// default is `/Library` (`config::download_dir`, which cannot be +/// called here because it takes a `tauri::AppHandle`). The database itself +/// always lives under `/.soulstream/`, which is the rule that keeps +/// stored paths portable. +fn db_path(data_dir: &Path) -> PathBuf { + let configured = std::fs::read_to_string(data_dir.join("config.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| { + v.get("download_dir") + .and_then(|d| d.as_str()) + .map(str::to_owned) + }) + .filter(|s| !s.is_empty()); + let library = configured.map_or_else(|| data_dir.join("Library"), PathBuf::from); + library.join(".soulstream").join("library.db") +} + +/// Read-only, and read-only is load-bearing — see the module note. +/// +/// A read-only connection to a WAL database still needs the `-shm` beside it. +/// The service runs as the same uid as the app that wrote it, so it is there +/// and readable; if it ever is not, this returns `Err` and the caller answers +/// with an empty tree rather than blocking the head unit. +fn open_read_only(db: &Path) -> rusqlite::Result { + let ro = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX; + match Connection::open_with_flags(db, ro) { + Ok(c) => Ok(c), + Err(first) => { + // A READ-ONLY connection to a WAL-mode database needs the `-shm` + // beside it, and cannot create one. SQLite deletes `-wal`/`-shm` + // when the LAST connection closes cleanly — so a library whose + // app exited normally is a WAL database (the mode lives in the + // header, it does not go away with the files) with no shared + // index and no writer to build one, and every read-only open of + // it fails with "unable to open database file". + // + // That is the normal state of a phone in a pocket, which makes it + // the normal state for a car. It does not reproduce after a + // force-stop, because that leaves both sidecars on disk — which + // is exactly how the prober first walked this tree green. + // + // So: ask for read-only, and when SQLite refuses, take a + // read-write handle and immediately make it `query_only`. That + // lets SQLite build the `-shm` it needs while the connection + // still cannot change a single row — the point was never the + // file mode, it was never writing to Kyle's library. CREATE is + // never in the flags either way, so a missing database stays an + // error instead of becoming an empty one. + let rw = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let conn = Connection::open_with_flags(db, rw).map_err(|_| first)?; + conn.pragma_update(None, "query_only", true)?; + Ok(conn) + } + } +} + +fn plural(n: i64, one: &str, many: &str) -> String { + if n == 1 { + format!("{n} {one}") + } else { + format!("{n} {many}") + } +} + +fn tabs() -> Vec { + TABS.iter() + .map(|(id, title)| Node { + id: (*id).to_owned(), + title: (*title).to_owned(), + subtitle: String::new(), + browsable: true, + playable: false, + art: None, + path: None, + }) + .collect() +} + +fn albums(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT a.id, a.title, a.artist, a.art_path, COUNT(t.id) + FROM albums a LEFT JOIN tracks t ON t.album_id = a.id + GROUP BY a.id + ORDER BY a.added_at DESC + LIMIT ?1", + )?; + let rows = stmt.query_map([ROW_CAP as i64], |r| { + let id: i64 = r.get(0)?; + let title: String = r.get(1)?; + let artist: String = r.get(2)?; + let art: Option = r.get(3)?; + let n: i64 = r.get(4)?; + Ok(Node { + id: format!("ss:album:{id}"), + title, + subtitle: format!("{artist} · {}", plural(n, "track", "tracks")), + browsable: true, + playable: false, + art, + path: None, + }) + })?; + rows.collect() +} + +fn artists(conn: &Connection) -> rusqlite::Result> { + // Artists are not a table — on the phone they are derived in JS by + // grouping the library, and the same grouping has to happen here or the + // car and the app would disagree about what an artist is. + let mut stmt = conn.prepare( + "SELECT a.artist, COUNT(*), MAX(a.artist_art_path) + FROM albums a + GROUP BY a.artist COLLATE NOCASE + ORDER BY a.artist COLLATE NOCASE + LIMIT ?1", + )?; + let rows = stmt.query_map([ROW_CAP as i64], |r| { + let artist: String = r.get(0)?; + let n: i64 = r.get(1)?; + let art: Option = r.get(2)?; + Ok(Node { + id: format!("ss:artist:{artist}"), + title: artist.clone(), + subtitle: plural(n, "album", "albums"), + browsable: true, + playable: false, + art, + path: None, + }) + })?; + rows.collect() +} + +fn artist_albums(conn: &Connection, artist: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT a.id, a.title, a.year, a.art_path, COUNT(t.id) + FROM albums a LEFT JOIN tracks t ON t.album_id = a.id + WHERE a.artist = ?1 COLLATE NOCASE + GROUP BY a.id + ORDER BY a.year DESC, a.title + LIMIT ?2", + )?; + let rows = stmt.query_map(rusqlite::params![artist, ROW_CAP as i64], |r| { + let id: i64 = r.get(0)?; + let title: String = r.get(1)?; + let year: Option = r.get(2)?; + let art: Option = r.get(3)?; + let n: i64 = r.get(4)?; + let count = plural(n, "track", "tracks"); + Ok(Node { + id: format!("ss:album:{id}"), + title, + subtitle: match year { + Some(y) => format!("{y} · {count}"), + None => count, + }, + browsable: true, + playable: false, + art, + path: None, + }) + })?; + rows.collect() +} + +fn album_tracks(conn: &Connection, album_id: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT t.id, t.title, t.artist, t.path, a.art_path + FROM tracks t JOIN albums a ON a.id = t.album_id + WHERE t.album_id = ?1 + ORDER BY t.disc_no, t.track_no, t.title", + )?; + let rows = stmt.query_map([album_id], |r| Ok(track_node(r)?))?; + rows.collect() +} + +fn saved(conn: &Connection) -> rusqlite::Result> { + // Everything in `tracks` is a local file on this phone, which is exactly + // what "plays with the app shut" means — the one thing about SoulStream + // that is genuinely different in a car. + let mut stmt = conn.prepare( + "SELECT t.id, t.title, t.artist, t.path, a.art_path + FROM tracks t JOIN albums a ON a.id = t.album_id + ORDER BY t.added_at DESC + LIMIT ?1", + )?; + let rows = stmt.query_map([ROW_CAP as i64], |r| Ok(track_node(r)?))?; + rows.collect() +} + +fn recent(conn: &Connection) -> rusqlite::Result> { + // `plays` records what was played by NAME, not by track id, so getting + // back to a playable row means matching the pair again. Anything played + // that is not in the library any more simply drops out of the join, which + // is the right answer for a tab whose whole promise is that tapping a row + // works. + let mut stmt = conn.prepare( + "SELECT t.id, t.title, t.artist, t.path, a.art_path, MAX(p.started_at) + FROM plays p + JOIN tracks t + ON t.title = p.title COLLATE NOCASE + AND t.artist = p.artist COLLATE NOCASE + JOIN albums a ON a.id = t.album_id + GROUP BY t.id + ORDER BY MAX(p.started_at) DESC + LIMIT ?1", + )?; + let rows = stmt.query_map([ROW_CAP as i64], |r| Ok(track_node(r)?))?; + rows.collect() +} + +fn track_node(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let id: i64 = r.get(0)?; + let title: String = r.get(1)?; + let artist: String = r.get(2)?; + let path: String = r.get(3)?; + let art: Option = r.get(4)?; + Ok(Node { + id: format!("ss:track:{id}"), + title, + subtitle: artist, + browsable: false, + playable: true, + art, + path: Some(path), + }) +} + +/// The whole tree, dispatched on the parent id. Unknown ids answer with +/// nothing, which is what a head unit asking about a node we have since +/// stopped publishing should get. +fn children(conn: &Connection, parent: &str) -> rusqlite::Result> { + match parent { + ROOT => Ok(tabs()), + "ss:albums" => albums(conn), + "ss:artists" => artists(conn), + "ss:saved" => saved(conn), + "ss:recent" => recent(conn), + _ => { + if let Some(id) = parent.strip_prefix("ss:album:") { + match id.parse::() { + Ok(n) => album_tracks(conn, n), + Err(_) => Ok(Vec::new()), + } + } else if let Some(name) = parent.strip_prefix("ss:artist:") { + // The rest of the id is the artist's name verbatim, however + // many colons it happens to contain. + artist_albums(conn, name) + } else { + Ok(Vec::new()) + } + } + } +} + +fn browse_json(data_dir: &str, parent: &str) -> String { + // THE ROOT NEVER TOUCHES THE DATABASE. The four tabs are a constant, and + // gating them on a connection means one unreadable library costs the car + // its whole structure — which is what "Media isn't available for this + // list" looked like the first time this ran on a head unit. A library + // that cannot be read should cost the ROWS, never the shape. + if parent == ROOT { + return rows(&tabs()); + } + let db = db_path(Path::new(data_dir)); + let nodes = match open_read_only(&db) { + Ok(conn) => children(&conn, parent).unwrap_or_else(|e| { + warn(&format!("auto browse {parent}: {e}")); + Vec::new() + }), + Err(e) => { + warn(&format!( + "auto browse: cannot open {} read-only: {e}", + db.display() + )); + Vec::new() + } + }; + rows(&nodes) +} + +fn rows(nodes: &[Node]) -> String { + serde_json::Value::Array(nodes.iter().map(Node::json).collect()).to_string() +} + +/// A cold service process has never run Tauri, so nothing has installed an +/// `android_logger` and `log::warn!` goes nowhere — which is why the first +/// failure here was silent. Write to the platform log directly. +fn warn(msg: &str) { + let tag = std::ffi::CString::new("SoulStream").unwrap_or_default(); + if let Ok(m) = std::ffi::CString::new(msg) { + // SAFETY: both pointers are valid NUL-terminated strings for the call. + unsafe { __android_log_write(5 /* WARN */, tag.as_ptr(), m.as_ptr()) }; + } +} + +extern "C" { + fn __android_log_write( + prio: std::ffi::c_int, + tag: *const std::ffi::c_char, + text: *const std::ffi::c_char, + ) -> std::ffi::c_int; +} + +/// `AutoNative.browse(dataDir, parentId)` — see +/// `crates/tauri-plugin-media-session/android/.../MediaService.kt`. +/// +/// The second argument is the `AutoNative` object itself: the Kotlin +/// declaration is a member of an `object`, so this is an instance method and +/// the receiver takes the slot a static method's `JClass` would. +#[no_mangle] +pub extern "system" fn Java_com_soulstream_mediasession_AutoNative_browse( + mut env: JNIEnv, + _this: JObject, + data_dir: JString, + parent: JString, +) -> jstring { + // Read both strings before anything else borrows `env`; a closure over it + // would have to be `mut` and then collide with the hand-back below. + let dir: Option = env.get_string(&data_dir).ok().map(Into::into); + let par: Option = env.get_string(&parent).ok().map(Into::into); + let out = match (dir, par) { + (Some(d), Some(p)) => browse_json(&d, &p), + _ => "[]".to_owned(), + }; + // Even the hand-back has to not panic across the boundary: an empty array + // built from a literal is the last resort. + env.new_string(out) + .or_else(|_| env.new_string("[]")) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e386fa..118d1f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,10 @@ /// library load, which cpal's AAudio backend needs and tao 0.35 does not do. #[cfg(target_os = "android")] mod android_glue; +/// Android Auto's browse tree, read read-only from a process that may hold +/// nothing but the media service — no Activity, no WebView, no `AppHandle`. +#[cfg(target_os = "android")] +mod auto_browse; mod commands; mod community; mod config;