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 `<dataDir>/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.
This commit is contained in:
1 parent
e1bf456743
commit
515b3da71f
6 files changed
+603
No files matched your search
Generated
+1
@@ -182,6 +182,7 @@ dependencies = [
|
||||
"rand 0.8.8",
|
||||
"realfft",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+126
@@ -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<LibraryResult<ImmutableList<MediaItem>>> =
|
||||
Futures.submit<LibraryResult<ImmutableList<MediaItem>>>(
|
||||
{
|
||||
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<MediaItem> {
|
||||
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<MediaItem>()
|
||||
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
|
||||
}
|
||||
@@ -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 `<dataDir>/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.
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<String>,
|
||||
/// Absolute path to the audio file, for playable rows. Round fifty-one is
|
||||
/// what does anything with it.
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/config.json` names `download_dir`; when it is null or empty the
|
||||
/// default is `<data_dir>/Library` (`config::download_dir`, which cannot be
|
||||
/// called here because it takes a `tauri::AppHandle`). The database itself
|
||||
/// always lives under `<library>/.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::<serde_json::Value>(&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<Connection> {
|
||||
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<Node> {
|
||||
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<Vec<Node>> {
|
||||
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<String> = 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<Vec<Node>> {
|
||||
// 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<String> = 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<Vec<Node>> {
|
||||
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<i64> = r.get(2)?;
|
||||
let art: Option<String> = 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<Vec<Node>> {
|
||||
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<Vec<Node>> {
|
||||
// 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<Vec<Node>> {
|
||||
// `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<Node> {
|
||||
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<String> = 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<Vec<Node>> {
|
||||
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::<i64>() {
|
||||
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<String> = env.get_string(&data_dir).ok().map(Into::into);
|
||||
let par: Option<String> = 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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user