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
+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
|
||||
}
|
||||
Reference in new issue
Block a user