Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Android embedded daemon support

Implement Android cdylib 'embedded-daemon' feature: link C firmware,
codecs, and Rust servers into librockbox_expo.so and expose native
JS bindings (hasAllFilesAccess, requestAllFilesAccess, rescanLibrary).
Default Android build script now enables the embedded-daemon feature.

Add CODECS_STATIC support: keep the firmware codec table as firmware_ci
and alias ci to a pointer to avoid linker/struct collisions. Route
firmware debug/logging to logcat and prefer ROCKBOX_LIBRARY env var
when scanning libraries.

+1189 -81
+78
CLAUDE.md
··· 368 368 in both modules, and add the typed `subscribe<Name>(cb, onError?)` helper to 369 369 `expo/lib/rockbox-client.ts`. 370 370 371 + #### Embedded daemon — Android cdylib (`embedded-daemon` feature) 372 + 373 + The Android build of `librockbox_expo.so` can host a **full in-process 374 + rockboxd**: C firmware + codecs + Rust gRPC/HTTP/GraphQL/MPD servers + AAudio 375 + sink + mDNS advertising. The phone becomes a symmetric peer of any LAN 376 + rockboxd, while keeping the existing tonic gRPC client to control other peers. 377 + 378 + Enable with `--features embedded-daemon` (the `expo/modules/rockbox-rpc/scripts/build-android.sh` 379 + script does this by default). Without the feature the .so is the thin 380 + ~6 MB remote-only client; with it, ~48 MB. 381 + 382 + ```sh 383 + PROFILE=release bash expo/modules/rockbox-rpc/scripts/build-android.sh 384 + ``` 385 + 386 + **Architecture (cdylib):** 387 + - Static-linked codecs (BINFMT_STATIC) — `lib/rbcodec/codecs/codecs.make` runs 388 + `objcopy --redefine-sym` per codec to make `__header`, `codec_main`, `codec_run`, 389 + `codec_start` distinct symbols. Codec lookup goes through `lc_static_table[]` 390 + in `firmware/target/hosted/android/cdylib/lc-android.c` instead of `dlopen`. 391 + - Per-target headless config: `firmware/export/config/androidcdylib.h` defines 392 + `CONFIG_BINFMT BINFMT_STATIC`, `CONFIG_PLATFORM (PLATFORM_HOSTED|PLATFORM_ANDROID)`, 393 + `ROCKBOX_SERVER`, `CONFIG_SERVER`, plus a `DEBUGF debugf` override so firmware 394 + diagnostics surface in logcat (debug-android.c routes `debugf` → 395 + `__android_log_print`). 396 + - New cdylib-only sources under `firmware/target/hosted/android/cdylib/`: 397 + `system-android.c` (boot + stdout/stderr→logcat shim), `pcm-aaudio.c` 398 + (AAudio sink), `lc-android.c` (codec table loader), `rb_zig_compat.c` 399 + (C compat layer for the 18 `rb_*` symbols `crates/sys` expects from 400 + the Zig wrapper), plus stubs `lcd-noop.c`, `button-noop.c`, etc. 401 + - `crates/expo/src/daemon.rs` wraps the firmware boot: 402 + `rb_daemon_start(configDir, musicDir, deviceName)` spawns a pthread that 403 + calls `main_c()`, then waits up to 30s for `crates/server::start_servers()` 404 + to bind gRPC :6061. Auto-runs an audio scan after gRPC binds (skipped if 405 + the library DB already has tracks; force with `RockboxClient.rescanLibrary()` 406 + or `ROCKBOX_UPDATE_LIBRARY=1`). 407 + - The daemon module is referenced from the Expo module's `OnCreate` lifecycle 408 + hook in `RockboxRpcModule.kt`, so the daemon boots at app launch and the 409 + process stays alive via the foreground `NowPlayingService`. 410 + 411 + **Permissions / paths:** 412 + - `MANAGE_EXTERNAL_STORAGE` declared in `expo/android/app/src/main/AndroidManifest.xml` 413 + — required so the filesystem-based scanner can read `/storage/emulated/0/Music` 414 + on API 33+. `READ_MEDIA_AUDIO` doesn't help (it only grants MediaStore queries). 415 + The `useAllFilesAccessPrompt()` hook in `expo/app/_layout.tsx` opens system 416 + Settings → "All files access" the first time the user runs the app. 417 + - The daemon sets `ROCKBOX_LIBRARY=<musicDir>` env var (canonical, read by 418 + `crates/{settings,server,graphql}`); previous builds set the misnomer 419 + `ROCKBOX_MUSIC_DIR` which nothing read. 420 + - `firmware/target/hosted/android/debug-android.c` routes firmware 421 + `printf`/`fprintf` and DEBUGF to logcat under tag `Rockbox`. 422 + `system-android.c::redirect_stdio_to_logcat` adds a pthread that pipes 423 + stdout/stderr fds to `__android_log_write` so even raw `printf` calls 424 + (the `[metadata]`/`[streamfd]` chatter Rockbox emits) are visible. 425 + 426 + **JS-callable controls (in addition to the remote-only surface):** 427 + - `RockboxClient.rescanLibrary()` — force a full audio scan 428 + - `RockboxClient.hasAllFilesAccess()` / `requestAllFilesAccess()` — Android 429 + permission gating 430 + - `RockboxNowPlaying.start()` — early foreground-service promotion (so the 431 + process survives backgrounding while the daemon is running) 432 + 433 + **Common pitfalls (see auto-memory):** 434 + - `pcm_sink::set_freq` receives an INDEX into `hw_freq_sampr[]`, not Hz — 435 + AAudio gets opened at "4 Hz", silently falls back to 48 kHz, 44.1 kHz 436 + audio plays ~9 % fast (chipmunk effect). Look up the rate first. 437 + - `apps/codecs.c::ci` (struct) collides with each codec's 438 + `codec_crt0.c::ci` (pointer) at link time. Firmware-side rename to 439 + `firmware_ci` keeps the type/size invariants distinct. 440 + - `apps/main.c` gates `server_init()` on `ROCKBOX_SERVER` but `apps/SOURCES` 441 + gates the .c COMPILATION on `CONFIG_SERVER` — define BOTH. 442 + - Android 14+ blocks `startForegroundService` from background process state 443 + even with `mediaPlayback` type. `startServiceCompat` checks importance 444 + before promoting; `refreshNotification` does the same before 445 + `startForeground`. 446 + 447 + See `crates/expo/README.md` for the full architecture writeup. 448 + 371 449 ## Useful commands 372 450 373 451 ```sh
+9
apps/codec_thread.c
··· 85 85 extern void audio_codec_update_offset(size_t offset); 86 86 extern void audio_codec_complete(int status); 87 87 extern void audio_codec_seek_complete(void); 88 + /* CODECS_STATIC builds (Android cdylib) rename the firmware-owned struct to 89 + * `firmware_ci` so it doesn't clash with each codec's `struct codec_api *ci` 90 + * pointer (see comment in codecs.c). The `#define ci firmware_ci` lets us 91 + * keep using `ci.X` member-access syntax in the firmware code unchanged. */ 92 + #ifdef CODECS_STATIC 93 + extern struct codec_api firmware_ci; 94 + #define ci firmware_ci 95 + #else 88 96 extern struct codec_api ci; /* from codecs.c */ 97 + #endif 89 98 90 99 /* Codec thread */ 91 100 static unsigned int codec_thread_id; /* For modifying thread priority later */
+28
apps/codecs.c
··· 70 70 71 71 static size_t codec_size; 72 72 73 + /* In CODECS_STATIC builds (Android cdylib), every codec is statically linked 74 + * into the same binary. Each codec's `codec_crt0.c` declares 75 + * struct codec_api *ci DATA_ATTR; (8 bytes — pointer) 76 + * but the firmware's `ci` here is a 264-byte struct. The linker silently 77 + * merges them into the firmware struct's storage; codec-side reads of 78 + * `ci` (typed as `struct codec_api *`) load the first 8 bytes (filesize) 79 + * and treat them as a pointer — undefined behaviour, eventually a SIGSEGV 80 + * inside e.g. `init_mad`'s `ci->memset(...)` call. 81 + * 82 + * Fix: keep the firmware-owned struct alive under a different name, and 83 + * give `ci` the same TYPE the codecs expect (a `struct codec_api *` 84 + * pointing at the firmware struct). Now both sides agree — 8 bytes, 85 + * pointer, points at the master function-pointer table. 86 + * 87 + * Firmware-side code accesses fields through `firmware_ci.X` (see 88 + * codec_thread.c, playback.c). Codec-side code keeps using `ci->X`. */ 89 + #ifdef CODECS_STATIC 90 + #define ci firmware_ci 91 + #endif 73 92 struct codec_api ci = { 74 93 75 94 0, /* filesize */ ··· 152 171 the API gets incompatible */ 153 172 154 173 }; 174 + 175 + #ifdef CODECS_STATIC 176 + /* Codec-side `ci` (matches `extern struct codec_api *ci` in codec_crt0.c 177 + * and codeclib.h). Both definitions have the same TYPE and SIZE now, so 178 + * the linker safely merges them — pointer storage everywhere. The init 179 + * value points at the firmware's master function-pointer table above. */ 180 + #undef ci 181 + struct codec_api *ci = &firmware_ci; 182 + #endif 155 183 156 184 void codec_get_full_path(char *path, const char *codec_root_fn) 157 185 {
+7
apps/playback.c
··· 121 121 extern unsigned int audio_thread_id; /* from audio_thread.c */ 122 122 extern struct event_queue audio_queue; /* from audio_thread.c */ 123 123 extern bool audio_is_initialized; /* from audio_thread.c */ 124 + /* See codecs.c — CODECS_STATIC renames the firmware struct to firmware_ci 125 + * to avoid colliding with each codec's `struct codec_api *ci` pointer. */ 126 + #ifdef CODECS_STATIC 127 + extern struct codec_api firmware_ci; 128 + #define ci firmware_ci 129 + #else 124 130 extern struct codec_api ci; /* from codecs.c */ 131 + #endif 125 132 126 133 /** Possible arrangements of the main buffer **/ 127 134 static enum audio_buffer_state
+80
autoconf/autoconf-android.h
··· 1 + /* This header was made by configure */ 2 + #ifndef __BUILD_AUTOCONF_H 3 + #define __BUILD_AUTOCONF_H 4 + 5 + /* lower case names match the what's exported in the Makefile 6 + * upper case name looks nicer in the code */ 7 + 8 + #define arch_none 0 9 + #define ARCH_NONE 0 10 + 11 + #define arch_arm64 1 12 + #define ARCH_ARM64 1 13 + 14 + #define arch_m68k 2 15 + #define ARCH_M68K 2 16 + 17 + #define arch_arm 3 18 + #define ARCH_ARM 3 19 + 20 + #define arch_mips 4 21 + #define ARCH_MIPS 4 22 + 23 + #define arch_x86 5 24 + #define ARCH_X86 5 25 + 26 + #define arch_amd64 6 27 + #define ARCH_AMD64 6 28 + 29 + #define ARM_PROFILE_CLASSIC 0 /* Classic ARM cores (<= ARMv6) */ 30 + #define ARM_PROFILE_MICRO 1 /* ARMv6/ARMv7+ M-profile cores */ 31 + #define ARM_PROFILE_APPLICATION 2 /* ARMv7+ A-profile cores */ 32 + 33 + /* Define target machine architecture */ 34 + #define ARCH arch_none 35 + /* Optionally define architecture version and/or profile */ 36 + 37 + 38 + 39 + /* Define endianess for the target or simulator platform */ 40 + #define ROCKBOX_LITTLE_ENDIAN 1 41 + 42 + /* Define the GCC version used for the build */ 43 + #define GCCNUM 1800 44 + 45 + /* Define this if you build rockbox to support the logf logging and display */ 46 + #undef ROCKBOX_HAS_LOGF 47 + 48 + /* Define this if you want logf to output to the serial port */ 49 + #undef LOGF_SERIAL 50 + 51 + /* Define this to record a chart with timings for the stages of boot */ 52 + #undef DO_BOOTCHART 53 + 54 + /* optional define for FM radio mod for iAudio M5 */ 55 + 56 + 57 + /* optional define for USB Serial */ 58 + 59 + 60 + /* optional defines for RTC mod for h1x0 */ 61 + 62 + 63 + 64 + /* the threading backend we use */ 65 + #define HAVE_SIGALTSTACK_THREADS 66 + 67 + /* lcd dimensions for application builds from configure */ 68 + #define LCD_WIDTH 320 69 + #define LCD_HEIGHT 480 70 + 71 + /* root of Rockbox */ 72 + #define ROCKBOX_DIR "/.rockbox" 73 + #define ROCKBOX_SHARE_PATH "/dev/null" 74 + #define ROCKBOX_BINARY_PATH "/dev/null" 75 + #define ROCKBOX_LIBRARY_PATH "/dev/null" 76 + 77 + /* linker feature test macro for validating cross-section references */ 78 + #undef HAVE_NOCROSSREFS_TO 79 + 80 + #endif /* __BUILD_AUTOCONF_H */
+410 -34
crates/expo/README.md
··· 1 - # `rockbox-expo` — C-ABI gRPC client for the Expo mobile app 1 + # `rockbox-expo` 2 2 3 - This crate produces a `staticlib` + `cdylib` that wraps the rockboxd gRPC 4 - client (built with [`tonic`]) and exposes a flat C ABI suitable for embedding 5 - in iOS (`.xcframework`) and Android (`jniLibs/<abi>/librockbox_expo.so`) 6 - builds. The companion [Expo Modules wrapper](../../expo/modules/rockbox-rpc/) 3 + The mobile-side Rust crate. Two builds in one workspace: 4 + 5 + | Build | Output | Size | Purpose | 6 + |---|---|---|---| 7 + | **Default** (`cargo build -p rockbox-expo`) | `staticlib` + `cdylib` | ~6 MB | Thin tonic gRPC client — controls a remote rockboxd over LAN. iOS, web, and "remote-only" Android variants use this. | 8 + | **`--features embedded-daemon`** (Android only) | `cdylib` | ~48 MB | Full in-process rockboxd: C firmware + 44 statically-linked codecs + all Rust sink crates + gRPC/HTTP/GraphQL/MPD servers + AAudio sink + mDNS discovery. The phone becomes a symmetric peer of any LAN rockboxd. | 9 + 10 + The Expo Modules wrapper at [`expo/modules/rockbox-rpc/`](../../expo/modules/rockbox-rpc/) 7 11 loads the resulting library and forwards calls through React Native. 8 12 9 - It is the **mobile counterpart** to the desktop client in [`gpui/`](../../gpui/); 10 - the surface mirrors `gpui/src/client.rs` 1:1 wherever it makes sense. 13 + It is the **mobile counterpart** to the desktop client in 14 + [`gpui/`](../../gpui/); the surface mirrors `gpui/src/client.rs` 1:1 15 + wherever it makes sense. 16 + 17 + --- 11 18 12 19 ## Why a separate crate? 13 20 14 - The `rockbox-rpc` crate (which the rockboxd server uses) pulls in heavy 15 - dependencies — `sqlx`, `typesense`, `library`, `reqwest` with native TLS, 16 - `rocksky`, etc. — that are painful to cross-compile to iOS / Android. This 17 - crate keeps deps minimal: 21 + `rockbox-rpc` (which the rockboxd server uses) pulls in `sqlx`, `typesense`, 22 + `library`, `reqwest` with native TLS, `rocksky`, etc. — painful to 23 + cross-compile to iOS / Android. The default build of `rockbox-expo` keeps 24 + deps minimal: 18 25 19 26 - `tonic` (transport + codegen + prost), client only 20 27 - `tokio` runtime (multi-thread, 2 worker threads) 21 28 - `prost`, `serde`, `serde_json`, `once_cell`, `futures-util` 22 29 - `rockbox-discovery` for LAN mDNS / Bonjour scans 23 30 24 - Proto bindings are generated from `proto/` (a symlink to 25 - `../rpc/proto`) via [`tonic-build`] in `build.rs`, with 26 - `build_server(false)` and a `type_attribute(".", "#[derive(serde::Serialize)]")` 27 - configuration so any response can be JSON-serialized in one line. 31 + Proto bindings are generated from `proto/` (a symlink to `../rpc/proto`) 32 + via [`tonic-build`] in `build.rs`, with `build_server(false)` and a 33 + `type_attribute(".", "#[derive(serde::Serialize)]")` so any response can 34 + be JSON-serialized in one line. 35 + 36 + The `embedded-daemon` feature pulls the heavy deps in (rockbox-server, 37 + rockbox-library with `fts5`, all the PCM sink crates, etc.) and links 38 + the whole thing alongside the C firmware archives. 39 + 40 + --- 28 41 29 42 ## Layout 30 43 31 44 ``` 32 45 crates/expo/ 33 - ├── Cargo.toml staticlib + cdylib, slim deps 34 - ├── build.rs tonic-build (client only) + serde derive on every proto 46 + ├── Cargo.toml staticlib + cdylib, slim base + embedded-daemon feature 47 + ├── build.rs tonic-build (client only) + firmware archive linking 35 48 ├── proto -> ../rpc/proto shared with the rest of the workspace 36 - └── src/lib.rs runtime, FFI surface, subscriptions 49 + └── src/ 50 + ├── lib.rs gRPC client surface (rb_play, rb_pause, …) 51 + ├── daemon.rs [embedded-daemon only] firmware boot + auto-scan 52 + └── jni_bridge.rs per-rb_* JNI shims (Java_…_rb_1*) 37 53 ``` 54 + 55 + --- 38 56 39 57 ## ABI conventions 40 58 ··· 45 63 the platform glue checks for that key and throws. 46 64 - Strings flow in as `*const c_char` (NUL-terminated UTF-8); collections 47 65 flow in as JSON-array C strings to keep the FFI narrow. 66 + - Negative return codes used for daemon ops: 67 + - `-22` invalid input (null / non-UTF-8) 68 + - `-38` not built (`-DEMBEDDED_DAEMON` not set — remote-only build) 69 + - `-110` timeout (gRPC didn't bind within deadline) 70 + - `-114` already starting / running (idempotent) 71 + 72 + --- 48 73 49 74 ## Surface map 50 75 51 76 | Group | Examples | 52 77 |-------|----------| 53 - | Init | `rb_set_server_url`, `rb_ping` | 78 + | Init | `rb_set_server_url`, `rb_set_http_url`, `rb_ping` | 54 79 | Playback | `rb_play / pause / play_pause / next / prev`, `rb_seek`, `rb_play_album / play_artist_tracks / play_track / play_directory` | 55 80 | Queue | `rb_jump_to_queue_position`, `rb_insert_tracks`, `rb_insert_track_next / last`, `rb_remove_from_queue`, `rb_shuffle_playlist`, `rb_get_playlist_current_json` | 56 81 | Library | `rb_get_tracks_json`, `rb_get_artists_json`, `rb_get_album_json`, `rb_search_json`, `rb_like_track / unlike_track`, `rb_get_liked_tracks_json` | ··· 63 88 | Stream pump | `rb_poll_event(subId, timeoutMs)`, `rb_unsubscribe(subId)` | 64 89 | Discovery constants | `rb_rockbox_service_name`, `rb_chromecast_service_name` | 65 90 | Memory | `rb_free_string` | 91 + | **Embedded daemon** | `rb_daemon_start(configDir, musicDir, deviceName)`, `rb_daemon_port`, `rb_daemon_state`, `rb_rescan_library` | 92 + 93 + --- 66 94 67 95 ## Streaming subscriptions 68 96 ··· 70 98 71 99 ```text 72 100 tonic / mdns-sd stream 73 - → tokio mpsc<String> (one queue per subscription) 101 + → tokio mpsc<String> (one queue per subscription) 74 102 → rb_poll_event(id, timeout_ms) -> *mut c_char 75 103 → Swift dispatch_async / Kotlin Dispatchers.IO loop 76 104 → sendEvent("rockbox.<topic>", payload) 77 105 ``` 78 106 79 - `rb_subscribe_*` returns an opaque `i32` subscription id. Each event JSON is 80 - the prost message for the topic (e.g. `StatusResponse`, `CurrentTrackResponse`, 107 + `rb_subscribe_*` returns an opaque `i32` subscription id. Each event JSON 108 + is the prost message for the topic (e.g. `StatusResponse`, `CurrentTrackResponse`, 81 109 `PlaylistResponse`) or a `DiscoveredService` snapshot for the mDNS topic. 82 110 83 111 Topics: `rockbox.status`, `rockbox.currentTrack`, `rockbox.playlist`, ··· 85 113 `{ "error": "..." }` payloads on the same channel; the platform glue 86 114 re-emits them on `rockbox.error`. 87 115 88 - ## Building 116 + --- 117 + 118 + ## Embedded daemon (Android cdylib only) 89 119 90 - Host-only sanity check: 120 + When built with `--features embedded-daemon`, the .so contains the entire 121 + rockboxd: 122 + 123 + ``` 124 + ┌────────────────────── librockbox_expo.so ───────────────────────────┐ 125 + │ │ 126 + │ JNI bridges (Java_…_rb_1*) │ 127 + │ │ │ 128 + │ ┌─────▼──────┐ ┌──────────────┐ ┌────────────────────┐ │ 129 + │ │ tonic gRPC │ │ daemon.rs │ │ rockbox-server │ │ 130 + │ │ client │───▶│ rb_daemon_* │───▶│ start_servers() │ │ 131 + │ │ (lib.rs) │ │ │ │ • gRPC :6061 │ │ 132 + │ └────────────┘ │ main_c() ────┼──┐ │ • HTTP :6063 │ │ 133 + │ │ │ │ │ • GraphQL :6062 │ │ 134 + │ └──────────────┘ │ │ • MPD :6600 │ │ 135 + │ │ │ • mDNS advertise │ │ 136 + │ │ └────────────────────┘ │ 137 + │ ▼ │ 138 + │ ┌─────────────────────────────────┐ │ 139 + │ │ C firmware (apps/, firmware/) │ │ 140 + │ │ • playback engine │ │ 141 + │ │ • metadata + buffering │ │ 142 + │ │ • 44 statically-linked codecs │ │ 143 + │ │ • DSP + replay-gain │ │ 144 + │ │ • PCM sinks → AAudio │ │ 145 + │ └────────┬────────────────────────┘ │ 146 + │ ▼ │ 147 + │ ┌─────────────────────────┐ │ 148 + │ │ Android system libs │ │ 149 + │ │ libaaudio, liblog, │ │ 150 + │ │ libandroid │ │ 151 + │ └─────────────────────────┘ │ 152 + └─────────────────────────────────────────────────────────────────────┘ 153 + ``` 154 + 155 + ### Boot sequence 156 + 157 + 1. App launches → Expo modules initialize → `RockboxRpcModule.OnCreate` 158 + fires (Kotlin). 159 + 2. Kotlin calls `rb_daemon_start(configDir, musicDir, deviceName)` via JNI: 160 + - `configDir` = `applicationContext.filesDir` (`/data/user/0/.../files`) 161 + - `musicDir` = `Environment.getExternalStoragePublicDirectory(DIRECTORY_MUSIC)` 162 + (`/storage/emulated/0/Music`) 163 + - `deviceName` = `android.os.Build.MODEL` 164 + 3. `daemon.rs::rb_daemon_start`: 165 + - Installs `tracing-android` subscriber → tag `rockbox` 166 + - Sets env vars: `HOME`, `ROCKBOX_LIBRARY` (canonical, NOT `ROCKBOX_MUSIC_DIR`), 167 + `ROCKBOX_DEVICE_NAME`, `ROCKBOX_PORT/GRAPHQL_PORT/TCP_PORT/MPD_PORT` 168 + - Spawns `rockbox-engine` pthread (2 MB stack) which calls `main_c()` 169 + 4. `main_c()` — the firmware boot in `apps/main.c`. Initializes kernel, 170 + threads, audio engine. Spawns: 171 + - **server thread** → `crates/server::start_servers()` — binds gRPC :6061, 172 + HTTP :6063, GraphQL :6062, MPD :6600 173 + - **broker thread** → `crates/server::start_broker()` — internal command bus 174 + 5. `rb_daemon_start` waits up to 30 s for gRPC :6061 to bind (TCP probe 175 + to localhost), then: 176 + - Calls `rb_set_server_url("http://127.0.0.1:6061")` so the in-process 177 + tonic client targets our own daemon 178 + - Calls `rb_set_http_url("http://127.0.0.1:6063")` for the REST surface 179 + - Spawns the **library scan thread**: opens the SQLite DB, and if it's 180 + empty (or `ROCKBOX_UPDATE_LIBRARY=1`), runs 181 + `rockbox_library::audio_scan::scan_audio_files($ROCKBOX_LIBRARY)` 182 + 6. Returns the gRPC port (`6061`) to JNI; Kotlin logs it. 183 + 184 + Subsequent `rb_daemon_start` calls return `-114` immediately (idempotent). 185 + 186 + ### Force keepalives 187 + 188 + The cdylib link uses `--gc-sections`, and rustc dead-code-strips rlibs 189 + that aren't visibly referenced. Each PCM sink crate (`rockbox-airplay`, 190 + `rockbox-slim`, `rockbox-chromecast`, `rockbox-upnp`) and `rbnetstream` 191 + ships C-ABI exports the firmware needs (`pcm_airplay_*`, `rb_net_open`, 192 + etc.) but rustc would strip them along with their crate. 193 + 194 + `daemon.rs` defends against this with `#[used] static _KEEPALIVE_*` 195 + constants that take the address of one real C-ABI fn from each crate — 196 + that's enough to pin the whole crate's `#[no_mangle]` set into the link. 197 + There's also a similar shim for the rockbox-server crate and for 198 + `start_server` / `start_servers` / `start_broker`. 199 + 200 + If a sink stops working after a refactor, check for missing keepalives. 201 + 202 + ### C firmware artefacts 203 + 204 + The `embedded-daemon` build links these archives produced by the 205 + `build-android-arm64/` Make tree (driven by 206 + `tools/configure --target=205`): 207 + 208 + - `librockbox.a`, `firmware/libfirmware.a`, `lib/librbcodec.a` 209 + - `lib/libfixedpoint.a`, `lib/libtlsf.a`, `lib/libskin_parser.a` 210 + - 44 codec entry-point archives (`flac.a`, `mpa.a`, `opus.a`, …) — 211 + bare-named, linked via Cargo's `+verbatim` modifier 212 + - ~30 codec helper libraries (`libfaad.a`, `libffmpegFLAC.a`, `libmad.a`, …) 213 + 214 + Linker arg `-Wl,-z,muldefs` is set in `build.rs` to tolerate duplicate 215 + ogg symbols across vorbis/opus/speex/tremor (each codec ships its own 216 + copy of libogg). 217 + 218 + The cdylib-specific firmware sources live under 219 + `firmware/target/hosted/android/cdylib/`: 220 + 221 + | File | Role | 222 + |---|---| 223 + | `system-android.c` | Headless system_init + power_off + stdout/stderr→logcat shim | 224 + | `pcm-aaudio.c` | AAudio PCM sink (replaces SDL audio) | 225 + | `lc-android.c` | `lc_open()` / `lc_get_header()` over the static `lc_static_table[]` | 226 + | `rb_zig_compat.c` | C compat layer for the 18 `rb_*` symbols `crates/sys` would otherwise pull from `zig/src/main.zig` | 227 + | `lcd-noop.c`, `button-noop.c`, `cpuinfo-noop.c`, `audiohw-noop.c` | Stubs — UI lives in React Native, not on the device LCD | 228 + 229 + --- 230 + 231 + ## Build commands 232 + 233 + ### Host-only sanity check (no firmware deps) 91 234 92 235 ```sh 93 236 cargo check -p rockbox-expo 94 237 ``` 95 238 96 - Cross-compile for mobile (driven by the [Expo module's build scripts](../../expo/modules/rockbox-rpc/scripts/)): 239 + ### iOS — remote-only client 240 + 241 + Produces `expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework`: 97 242 98 243 ```sh 99 - # iOS — produces ../../expo/modules/rockbox-rpc/ios/RockboxExpo.xcframework 100 244 rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 101 - ( cd ../../expo/modules/rockbox-rpc && bun run build:ios ) 245 + ( cd expo/modules/rockbox-rpc && bun run build:ios ) 246 + ``` 247 + 248 + iOS doesn't currently ship the embedded daemon (the firmware tree 249 + isn't yet cross-compiled to Apple Silicon). The Swift module's daemon 250 + externs return `-38` (ENOSYS). 251 + 252 + ### Android — embedded daemon (default) 253 + 254 + Produces `expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so`. 255 + Two stages: first build the C firmware archives into `build-android-arm64/`, 256 + then cross-compile the Rust cdylib that links them together. 257 + 258 + #### Prereqs (one-time per machine) 259 + 260 + ```sh 261 + # Android NDK r25+ (r27.1 verified). Either install via Android Studio's 262 + # SDK Manager → SDK Tools → NDK (Side by side), or download from 263 + # https://developer.android.com/ndk/downloads 264 + export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/27.1.12297006 # macOS path 265 + # or e.g. $HOME/Android/Sdk/ndk/27.1.12297006 on Linux 102 266 103 - # Android — produces ../../expo/modules/rockbox-rpc/android/src/main/jniLibs/<abi>/librockbox_expo.so 267 + # Rust cross-compile target + cargo-ndk 268 + rustup target add aarch64-linux-android 104 269 cargo install cargo-ndk 105 - rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android 106 - export ANDROID_NDK_HOME=... # NDK r25+ 107 - ( cd ../../expo/modules/rockbox-rpc && bun run build:android ) 270 + ``` 271 + 272 + Optional but highly recommended: add the matching `armeabi-v7a` and 273 + `x86_64` targets if you need to ship for those ABIs too. The build script 274 + currently only builds `arm64-v8a` because that's the only `build-android-<abi>` 275 + firmware tree that's pre-configured. 276 + 277 + #### Stage 1: configure + build the firmware archives 278 + 279 + The firmware uses Rockbox's autotools-style `configure` script. Run it 280 + **once** to generate `build-android-arm64/Makefile`, then `make` for any 281 + C-side edit afterwards. 282 + 283 + ```sh 284 + # One-time: configure the firmware build dir for the Android cdylib target. 285 + # Target 205 = androidcdylib (model name) = our headless cdylib target. 286 + # Default ABI is arm64-v8a, default API level is 26 (required for AAudio). 287 + mkdir -p build-android-arm64 288 + cd build-android-arm64 289 + ../tools/configure \ 290 + --target=androidcdylib \ 291 + --type=N \ 292 + --ram=256 \ 293 + --rbdir=/.rockbox 294 + cd .. 295 + ``` 296 + 297 + The configure script reads two env vars when it sees the androidcdylib 298 + target — set them before running if you need non-default values: 299 + 300 + | Var | Default | Purpose | 301 + |---|---|---| 302 + | `ANDROID_NDK_HOME` | _(none — required)_ | Path to NDK install root | 303 + | `ANDROID_TARGET_ABI` | `arm64-v8a` | One of `arm64-v8a` / `armeabi-v7a` / `x86_64` | 304 + | `ANDROID_API_LEVEL` | `26` | Minimum SDK; **don't go below 26** (AAudio requires it) | 305 + 306 + For a 32-bit ARM build, e.g.: 307 + 308 + ```sh 309 + mkdir -p build-android-armv7 310 + cd build-android-armv7 311 + ANDROID_TARGET_ABI=armeabi-v7a ANDROID_API_LEVEL=26 \ 312 + ../tools/configure --target=androidcdylib --type=N --ram=256 --rbdir=/.rockbox 313 + ``` 314 + 315 + …then build the archives. **Re-run after any C-side change**: 316 + 317 + ```sh 318 + ( cd build-android-arm64 && make -j8 ) 319 + ``` 320 + 321 + Outputs (consumed by `crates/expo/build.rs` at link time): 322 + 323 + ``` 324 + build-android-arm64/ 325 + ├── librockbox.a apps/ + most of firmware/ 326 + ├── firmware/libfirmware.a low-level firmware glue 327 + ├── lib/librbcodec.a rbcodec (metadata + DSP) 328 + ├── lib/libfixedpoint.a fixed-point math 329 + ├── lib/libtlsf.a memory allocator 330 + ├── lib/libskin_parser.a skin / theme parser (vestigial) 331 + └── lib/rbcodec/codecs/ 332 + ├── flac.a, mpa.a, opus.a, … 44 codec entry-point archives (bare-named) 333 + └── libffmpegFLAC.a, libfaad.a, libmad.a, … ~30 codec helper libs 334 + ``` 335 + 336 + The Make build is **incremental** — touch one C file and `make` rebuilds 337 + just that .o and re-archives the affected .a. If a header in 338 + `firmware/export/config/androidcdylib.h` changes (or any other broadly-included 339 + header), most of the tree recompiles. 340 + 341 + #### Stage 2: build the cdylib 342 + 343 + ```sh 344 + PROFILE=release bash expo/modules/rockbox-rpc/scripts/build-android.sh 345 + ``` 346 + 347 + What the script does: 348 + 349 + ```sh 350 + cargo ndk \ 351 + -t arm64-v8a \ 352 + --platform 26 \ # AAudio requires API 26 353 + -o expo/modules/rockbox-rpc/android/src/main/jniLibs \ 354 + build -p rockbox-expo \ 355 + --features embedded-daemon \ 356 + --release 357 + ``` 358 + 359 + `--features embedded-daemon` is the critical flag — without it, the cdylib 360 + is the 6 MB remote-only tonic client (no firmware linked in), and Kotlin 361 + will log `embedded daemon not built into this .so (remote-only mode)` at 362 + boot. Override the feature set if you want a remote-only build for fast 363 + JS iteration: 364 + 365 + ```sh 366 + FEATURES="" bash expo/modules/rockbox-rpc/scripts/build-android.sh 108 367 ``` 109 368 369 + The Rust crate's `build.rs` automatically picks up the firmware archives 370 + from `build-android-arm64/` via the `cargo:rerun-if-changed=…/static-libs.stamp` 371 + directive — touching the firmware causes the cdylib to relink on the next 372 + cargo invocation. Override the firmware dir with 373 + `ROCKBOX_FIRMWARE_DIR=/elsewhere`. 374 + 375 + Verify the build: 376 + 377 + ```sh 378 + ls -lh expo/modules/rockbox-rpc/android/src/main/jniLibs/arm64-v8a/librockbox_expo.so 379 + # embedded-daemon: ~48 MB 380 + # remote-only: ~6 MB 381 + 382 + # Spot-check that the daemon entry points are exported: 383 + $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-nm \ 384 + expo/modules/rockbox-rpc/android/src/main/jniLibs/arm64-v8a/librockbox_expo.so \ 385 + | grep -E " T (rb_daemon_start|main_c|server_init|start_servers)$" 386 + ``` 387 + 388 + Expected output: 389 + 390 + ``` 391 + ... T main_c 392 + ... T rb_daemon_start 393 + ... T server_init 394 + ... T start_servers 395 + ``` 396 + 397 + If `main_c` is missing, the firmware archives didn't get linked — verify 398 + `--features embedded-daemon` was actually passed and that 399 + `build-android-arm64/static-libs.stamp` exists. 400 + 401 + #### Quick full rebuild (after pulling) 402 + 403 + ```sh 404 + # Refresh firmware first (incremental) 405 + ( cd build-android-arm64 && make -j8 ) 406 + 407 + # Then relink the cdylib 408 + PROFILE=release bash expo/modules/rockbox-rpc/scripts/build-android.sh 409 + 410 + # Bundle into the app 411 + ( cd expo && bunx expo run:android ) 412 + ``` 413 + 414 + If `build-android-arm64/` doesn't exist yet (fresh clone), do the 415 + `tools/configure` step from "Stage 1" first. 416 + 417 + ### Android — remote-only client (no firmware) 418 + 419 + Override the script's default to build the lighter client (useful for 420 + fast JS iteration when the daemon work isn't needed): 421 + 422 + ```sh 423 + FEATURES="" bash expo/modules/rockbox-rpc/scripts/build-android.sh 424 + ``` 425 + 426 + ### Bundle into the Expo app 427 + 428 + ```sh 429 + ( cd expo && bunx expo run:android ) # or run:ios 430 + ``` 431 + 432 + `bunx expo prebuild` regenerates `android/`. Avoid `prebuild --clean` 433 + on Android — the manifest is hand-edited (`MANAGE_EXTERNAL_STORAGE`, 434 + `xmlns:tools`) and `--clean` will wipe those edits. 435 + 436 + --- 437 + 110 438 ## Adding a new RPC 111 439 112 440 1. Add a `rb_<name>` wrapper in `src/lib.rs`. For unit ops, use the 113 441 `simple_call!` macro or write `run_unit(async move { ... })`. For reads, 114 442 `unwrap_or_err_string(res.map(|r| r.into_inner()))` does the JSON wrap. 115 - 2. Add the matching extern + `Function` / `AsyncFunction` in both 443 + 2. Add a JNI bridge in `src/jni_bridge.rs`: 444 + `Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1<name>`. Note the 445 + JNI mangling — `_` in Java method names becomes `_1` in the C symbol. 446 + 3. Add the matching extern + `Function` / `AsyncFunction` in both 116 447 `expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift` and 117 448 `.../RockboxRpcModule.kt`. 118 - 3. Add the typed signature to `expo/modules/rockbox-rpc/src/index.ts` and a 449 + 4. Add the typed signature to `expo/modules/rockbox-rpc/src/index.ts` and a 119 450 one-line forwarder on `RockboxClient` in `expo/lib/rockbox-client.ts`. 120 - 4. Rebuild the native libs (`build:ios` / `build:android`); Metro doesn't 451 + 5. Rebuild the native libs (`build:ios` / `build:android`); Metro doesn't 121 452 pick up native changes automatically. 122 453 123 454 For server-streaming RPCs, follow the `spawn_stream(...)` pattern, declare 124 455 the matching event topic in `Events(...)` on both platforms, register a 125 456 `Function("subscribe<Name>")` Function, and add a typed 126 457 `subscribe<Name>(cb, onError?)` helper to `expo/lib/rockbox-client.ts`. 458 + 459 + --- 460 + 461 + ## Logging & diagnostics 462 + 463 + Tag map: 464 + 465 + | logcat tag | Source | 466 + |---|---| 467 + | `rockbox` | Rust `tracing::*` calls (default level: per-crate `debug`, see `daemon.rs::install_logcat_subscriber`) | 468 + | `Rockbox` | C firmware `printf`/`fprintf` and `DEBUGF`/`logf`/`panicf` (routed via `debug-android.c` and the stdout/stderr pipe in `system-android.c`) | 469 + | `rockbox-engine` | `system-android.c` boot diagnostics (cgroup/SELinux denials, etc.) | 470 + | `rb-system-android`, `rb-pcm-aaudio` | other cdylib C tags | 471 + | `RockboxRpc` | Kotlin Log calls in `RockboxRpcModule.kt` | 472 + | `RockboxNowPlaying` | Kotlin Log calls in `NowPlayingService.kt` | 473 + 474 + Quick capture recipe: 475 + 476 + ```sh 477 + PID=$(adb shell pidof com.tsirysndr.Rockbox) 478 + adb logcat -c 479 + adb logcat -v time --pid=$PID 480 + ``` 481 + 482 + Override Rust verbosity at runtime by setting `RUST_LOG` BEFORE the daemon 483 + starts (e.g. `setprop log.tag.rockbox D` is consulted on next boot). 484 + 485 + --- 486 + 487 + ## Known pitfalls (also in `MEMORY.md`) 488 + 489 + | Symptom | Cause | Fix | 490 + |---|---|---| 491 + | `embedded daemon not built into this .so (remote-only mode)` | Build script ran without `--features embedded-daemon` | Use `expo/modules/rockbox-rpc/scripts/build-android.sh` (defaults to enabled) | 492 + | `dlopen failed: cannot locate symbol "server_init"` | `CONFIG_SERVER` not defined → `apps/SOURCES` skips `server_thread.c` compilation | Set both `ROCKBOX_SERVER` and `CONFIG_SERVER` in `androidcdylib.h` | 493 + | `Codec: cannot read file` for every track | Codec naming uses Java-shell `libNAME.so` convention but `lc_static_table[]` has bare `<name>.codec` entries | Gate the `libNAME.so` override in `lib/rbcodec/metadata/metadata.h` on `!CODECS_STATIC` | 494 + | SIGSEGV in `init_mad` (or any codec init) at small fault address | `ci` symbol collision: 264-byte struct (codecs.c) merged into 8-byte pointer storage (codec_crt0.c) | Firmware-side rename: `firmware_ci` for the struct, `ci` for the pointer (both 8 bytes, same type) | 495 + | Audio plays at chipmunk speed (~9 % too fast) | `pcm_sink::set_freq` receives an INDEX into `hw_freq_sampr[]`, not Hz; AAudio gets opened at "4 Hz", silently falls back to 48 kHz | `pcm-aaudio.c::sink_set_freq` looks up `hw_freq_sampr[freq_index]` first | 496 + | `ForegroundServiceStartNotAllowedException` on play | Android 14+ blocks `startForegroundService` from background process state (`uidState: SVC`) even with `mediaPlayback` type | `RockboxNowPlayingModule.startServiceCompat` and `NowPlayingService.refreshNotification` check `ActivityManager.getMyMemoryState().importance` before promoting | 497 + | ENOENT when the GraphQL `treeGetEntries` resolver browses `Music` | Daemon set `ROCKBOX_MUSIC_DIR` but the resolvers read `ROCKBOX_LIBRARY` | Set `ROCKBOX_LIBRARY` in `daemon.rs::configure_environment` | 498 + | Library DB stays empty even after browsing works | Embedded daemon doesn't run the desktop CLI's startup scan | `daemon.rs::spawn_library_scan` runs after gRPC binds; force re-scan via `RockboxClient.rescanLibrary()` | 499 + | `Permission denied` reading `/storage/emulated/0/Music` on API 33+ | `READ_EXTERNAL_STORAGE` is ignored on `targetSdk=33+`; `READ_MEDIA_AUDIO` only grants MediaStore queries | `MANAGE_EXTERNAL_STORAGE` in manifest + `useAllFilesAccessPrompt()` opens system Settings | 500 + | Daemon dies after the app backgrounds for a few minutes | App process killed for memory; daemon dies with it | NowPlayingService is a foreground service — keep it running via `RockboxNowPlaying.start()` at app launch (called from `_layout.tsx`) | 501 + 502 + --- 127 503 128 504 ## Skipped vs. `gpui/src/client.rs` 129 505
+114 -15
crates/expo/src/daemon.rs
··· 42 42 // and dlopen fails at runtime. 43 43 fn start_server(); 44 44 fn start_servers(); 45 + // start_broker is the third entry point — apps/broker_thread.c::broker_init 46 + // spawns a kernel thread that calls into it. Same dead-code-strip risk 47 + // as start_server, so it gets the same keepalive treatment. 48 + fn start_broker(); 45 49 } 46 50 47 - /// `#[used]` keepalives: take the address of start_server / start_servers 48 - /// so the symbols themselves don't get GC'd at link time. 51 + /// `#[used]` keepalives: take the address of start_server / start_servers / 52 + /// start_broker so the symbols themselves don't get GC'd at link time. 49 53 #[used] 50 54 static _KEEPALIVE_START_SERVER: unsafe extern "C" fn() = start_server; 51 55 #[used] 52 56 static _KEEPALIVE_START_SERVERS: unsafe extern "C" fn() = start_servers; 57 + #[used] 58 + static _KEEPALIVE_START_BROKER: unsafe extern "C" fn() = start_broker; 53 59 54 60 /// Force-pull rockbox-server's rlib into the link. `extern "C"` decls alone 55 61 /// don't do this — rustc treats them as external and waits for the linker ··· 149 155 // env exists. We're called from JNI before the engine pthread spawns. 150 156 std::env::set_var("HOME", config_dir); 151 157 std::env::set_var("ROCKBOX_DEVICE_NAME", device_name); 152 - std::env::set_var("ROCKBOX_MUSIC_DIR", music_dir); 158 + // Canonical music-dir env var read by crates/{settings,server,graphql,sys}. 159 + // ROCKBOX_MUSIC_DIR was a misnomer — nothing reads it. The browse 160 + // resolvers fall back to $HOME/Music when this is unset, which on Android 161 + // resolves to /data/.../files/Music (doesn't exist) → ENOENT. 162 + std::env::set_var("ROCKBOX_LIBRARY", music_dir); 153 163 154 164 // mDNS-advertised LAN ports (match crates/discovery defaults). 155 165 if std::env::var_os("ROCKBOX_PORT").is_none() { ··· 185 195 std::panic::set_hook(Box::new(|info| { 186 196 tracing::error!(target: "rockbox", "Rust panic: {}", info); 187 197 })); 198 + // Default to debug for our crates + info for the noisy 3rd-party ones. 199 + // Override at any time with `setprop log.tag.rockbox D` from adb shell, 200 + // or by setting RUST_LOG before the app launches (e.g. via the build). 201 + let default_filter = "info,rockbox_expo=debug,rockbox_server=debug,rockbox_rpc=debug,\ 202 + rockbox_graphql=debug,rockbox_library=debug,rockbox_sys=debug,\ 203 + rockbox_airplay=debug,rockbox_chromecast=debug,rockbox_slim=debug,\ 204 + rockbox_upnp=debug"; 188 205 let _ = tracing_subscriber::registry() 189 206 .with( 190 207 tracing_subscriber::EnvFilter::try_from_default_env() 191 - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), 208 + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_filter)), 192 209 ) 193 210 .with(tracing_android::layer("rockbox").expect("init logcat layer")) 194 211 .try_init(); ··· 233 250 install_logcat_subscriber(); 234 251 tracing::info!("daemon: install_logcat_subscriber done"); 235 252 configure_environment(config_dir, music_dir, device_name); 236 - tracing::info!("daemon: env configured (HOME={} MUSIC={} NAME={})", 237 - config_dir, music_dir, device_name); 253 + tracing::info!( 254 + "daemon: env configured (HOME={} MUSIC={} NAME={})", 255 + config_dir, 256 + music_dir, 257 + device_name 258 + ); 238 259 239 260 let port: u16 = std::env::var("ROCKBOX_PORT") 240 261 .ok() ··· 251 272 .stack_size(2 * 1024 * 1024) 252 273 .spawn(move || { 253 274 tracing::info!("rockbox-engine: thread started, calling main_c()"); 254 - let rc = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { 255 - unsafe { main_c() } 256 - })); 275 + let rc = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { main_c() })); 257 276 match rc { 258 277 Ok(code) => tracing::warn!("rockbox-engine: main_c returned rc={}", code), 259 - Err(p) => tracing::error!("rockbox-engine: PANICKED — {:?}", 260 - p.downcast_ref::<&str>() 261 - .map(|s| *s) 262 - .or_else(|| p.downcast_ref::<String>().map(|s| s.as_str())) 263 - .unwrap_or("<non-string panic>")), 278 + Err(p) => tracing::error!( 279 + "rockbox-engine: PANICKED — {:?}", 280 + p.downcast_ref::<&str>() 281 + .map(|s| *s) 282 + .or_else(|| p.downcast_ref::<String>().map(|s| s.as_str())) 283 + .unwrap_or("<non-string panic>") 284 + ), 264 285 } 265 286 STATE.store(STATE_STOPPED, Ordering::Release); 266 287 LOCAL_PORT.store(0, Ordering::Release); ··· 269 290 270 291 tracing::info!("daemon: waiting up to 30s for gRPC :{} to bind", port); 271 292 if !wait_for_grpc(port, Instant::now() + Duration::from_secs(30)) { 272 - tracing::error!("daemon: gRPC server did not bind within 30s — engine stuck or crashed silently"); 293 + tracing::error!( 294 + "daemon: gRPC server did not bind within 30s — engine stuck or crashed silently" 295 + ); 273 296 STATE.store(STATE_STOPPED, Ordering::Release); 274 297 return -110; 275 298 } ··· 288 311 let http_url_c = http_url.as_ptr() as *const c_char; 289 312 let _ = crate::rb_set_http_url(http_url_c); 290 313 314 + spawn_library_scan(/* force */ false); 315 + 291 316 port as i32 317 + } 318 + 319 + /// Re-scan trigger callable from JS. Forces a full rescan of `$ROCKBOX_LIBRARY` 320 + /// regardless of how many tracks are already indexed. Returns 0 immediately 321 + /// (the scan runs in the background — watch logcat for "scan: ..." lines). 322 + /// Returns -1 if the daemon isn't running. 323 + #[no_mangle] 324 + pub extern "C" fn rb_rescan_library() -> c_int { 325 + if STATE.load(Ordering::Acquire) != STATE_RUNNING { 326 + return -1; 327 + } 328 + spawn_library_scan(/* force */ true); 329 + 0 330 + } 331 + 332 + /// Mirror what the desktop `crates/cli` does at boot: open the library DB 333 + /// and run `scan_audio_files($ROCKBOX_LIBRARY)`. With `force=false` we skip 334 + /// the scan when the DB already has tracks (startup path); `force=true` 335 + /// always scans (manual re-scan / `ROCKBOX_UPDATE_LIBRARY=1`). 336 + /// 337 + /// Spawned on its own OS thread + tokio current-thread runtime so we don't 338 + /// block the daemon boot path or the JNI caller. 339 + fn spawn_library_scan(force_arg: bool) { 340 + thread::Builder::new() 341 + .name("rockbox-library-scan".into()) 342 + .stack_size(2 * 1024 * 1024) 343 + .spawn(move || { 344 + let path = std::env::var("ROCKBOX_LIBRARY").unwrap_or_else(|_| { 345 + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); 346 + format!("{}/Music", home) 347 + }); 348 + let force = force_arg 349 + || matches!( 350 + std::env::var("ROCKBOX_UPDATE_LIBRARY").as_deref(), 351 + Ok("1") | Ok("true") 352 + ); 353 + tracing::info!("scan: target={} force={}", path, force); 354 + 355 + let rt = match tokio::runtime::Builder::new_current_thread() 356 + .enable_all() 357 + .build() 358 + { 359 + Ok(rt) => rt, 360 + Err(e) => { 361 + tracing::error!("scan: tokio runtime build failed: {e}"); 362 + return; 363 + } 364 + }; 365 + 366 + rt.block_on(async move { 367 + let pool = match rockbox_library::create_connection_pool().await { 368 + Ok(p) => p, 369 + Err(e) => { 370 + tracing::error!("scan: open library DB failed: {e}"); 371 + return; 372 + } 373 + }; 374 + let count = rockbox_library::repo::track::all(pool.clone()) 375 + .await 376 + .map(|t| t.len()) 377 + .unwrap_or(0); 378 + if count > 0 && !force { 379 + tracing::info!("scan: library has {} tracks, skipping (force=false)", count); 380 + return; 381 + } 382 + 383 + tracing::info!("scan: scanning {} ...", path); 384 + match rockbox_library::audio_scan::scan_audio_files(pool, path.into()).await { 385 + Ok(files) => tracing::info!("scan: done, {} files", files.len()), 386 + Err(e) => tracing::error!("scan: failed: {e}"), 387 + } 388 + }); 389 + }) 390 + .expect("spawn rockbox-library-scan thread"); 292 391 } 293 392 294 393 /// Returns the gRPC port of the running daemon, or 0 if not running.
+18
crates/expo/src/jni_bridge.rs
··· 619 619 crate::daemon::rb_daemon_state() as jint 620 620 } 621 621 622 + #[cfg(feature = "embedded-daemon")] 623 + #[no_mangle] 624 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1rescan_1library( 625 + _env: JNIEnv, 626 + _cls: JClass, 627 + ) -> jint { 628 + crate::daemon::rb_rescan_library() as jint 629 + } 630 + 622 631 // Stubs for builds without embedded-daemon (e.g. iOS today, desktop dev). 623 632 // They return -38 (ENOSYS-ish) so callers know the daemon isn't available. 624 633 #[cfg(not(feature = "embedded-daemon"))] ··· 650 659 ) -> jint { 651 660 0 652 661 } 662 + 663 + #[cfg(not(feature = "embedded-daemon"))] 664 + #[no_mangle] 665 + pub unsafe extern "system" fn Java_expo_modules_rockboxrpc_RockboxRpcModule_rb_1rescan_1library( 666 + _env: JNIEnv, 667 + _cls: JClass, 668 + ) -> jint { 669 + -38 670 + }
+6 -1
crates/server/src/handlers/system.rs
··· 34 34 state: web::Data<AppState>, 35 35 query: web::Query<ScanQuery>, 36 36 ) -> HandlerResult { 37 + // Mirror the resolution rule used by browse / settings: prefer 38 + // $ROCKBOX_LIBRARY (set by the embedded-daemon module on Android to 39 + // /storage/emulated/0/Music), fall back to $HOME/Music for desktop. 40 + // Without this the scan defaults to the app sandbox dir on Android 41 + // and quietly indexes 0 tracks. 37 42 let home = env::var("HOME").map_err(ErrorInternalServerError)?; 38 - let music_library = format!("{}/Music", home); 43 + let music_library = env::var("ROCKBOX_LIBRARY").unwrap_or_else(|_| format!("{}/Music", home)); 39 44 40 45 let path = query.path.clone().unwrap_or_else(|| music_library.clone()); 41 46
+63 -1
expo/app/_layout.tsx
··· 6 6 import { Stack } from "expo-router"; 7 7 import * as SplashScreen from "expo-splash-screen"; 8 8 import { StatusBar } from "expo-status-bar"; 9 - import { useEffect, useState } from "react"; 9 + import { useEffect, useRef, useState } from "react"; 10 + import { Alert, AppState, Platform } from "react-native"; 10 11 import "react-native-reanimated"; 11 12 12 13 import { PersistentMiniPlayer } from "@/components/persistent-mini-player"; 13 14 import { TrackContextMenu } from "@/components/track-context-menu"; 14 15 import { Colors } from "@/constants/theme"; 15 16 import { PlayerProvider } from "@/lib/player-context"; 17 + import { RockboxClient } from "@/lib/rockbox-client"; 16 18 import { RockboxStreams } from "@/lib/rockbox-streams"; 17 19 18 20 SplashScreen.preventAutoHideAsync().catch(() => {}); ··· 67 69 if (fontsLoaded) SplashScreen.hideAsync().catch(() => {}); 68 70 }, [fontsLoaded]); 69 71 72 + // Android API 33+: ask the user once for "All files access" so the 73 + // embedded daemon's filesystem-based scanner can read /storage/emulated/0/Music. 74 + // No-op on iOS / web (hasAllFilesAccess() always returns true there). 75 + // Re-checks when the app foregrounds so the prompt clears once granted. 76 + useAllFilesAccessPrompt(); 77 + 70 78 if (!fontsLoaded) return null; 71 79 72 80 return ( ··· 139 147 </ThemeProvider> 140 148 ); 141 149 } 150 + 151 + function useAllFilesAccessPrompt() { 152 + const askedThisSession = useRef(false); 153 + 154 + useEffect(() => { 155 + if (Platform.OS !== "android") return; 156 + 157 + const check = () => { 158 + if (askedThisSession.current) return; 159 + // The native check returns true on iOS/web and on pre-API-30 Android, 160 + // so we'll only ever prompt on Android 11+ where it's actually needed. 161 + let granted = true; 162 + try { 163 + granted = RockboxClient.hasAllFilesAccess(); 164 + } catch { 165 + return; 166 + } 167 + if (granted) return; 168 + 169 + askedThisSession.current = true; 170 + Alert.alert( 171 + "Allow access to your music", 172 + "Rockbox needs \"All files access\" to scan your Music folder. " + 173 + "Tap Open Settings, toggle the switch on, then come back.", 174 + [ 175 + { text: "Not now", style: "cancel" }, 176 + { 177 + text: "Open Settings", 178 + onPress: () => { 179 + try { 180 + RockboxClient.requestAllFilesAccess(); 181 + } catch { 182 + // Settings intent failure — silently ignore; the user can 183 + // still grant from Apps → Rockbox manually. 184 + } 185 + }, 186 + }, 187 + ], 188 + { cancelable: true }, 189 + ); 190 + }; 191 + 192 + check(); 193 + const sub = AppState.addEventListener("change", (state) => { 194 + if (state === "active") { 195 + // Reset the once-per-session lock when the user comes back from 196 + // Settings so a "no" then "yes" round-trip clears the alert. 197 + askedThisSession.current = false; 198 + check(); 199 + } 200 + }); 201 + return () => sub.remove(); 202 + }, []); 203 + }
+18
expo/lib/rockbox-client.ts
··· 267 267 return require_().chromecastServiceName(); 268 268 }, 269 269 270 + // Android "All files access" gate. Always true on iOS / web. 271 + hasAllFilesAccess(): boolean { 272 + if (!isAvailable) return true; 273 + return require_().hasAllFilesAccess(); 274 + }, 275 + requestAllFilesAccess(): boolean { 276 + if (!isAvailable) return false; 277 + return require_().requestAllFilesAccess(); 278 + }, 279 + 280 + /** Force a full library rescan of the music dir. Returns the native rc: 281 + * 0 = queued, -1 = daemon not running, -38 = remote-only build. The scan 282 + * runs in the background — watch logcat for "scan: ..." progress lines. */ 283 + rescanLibrary(): number { 284 + if (!isAvailable) return -38; 285 + return require_().rescanLibrary(); 286 + }, 287 + 270 288 // ── Streaming subscriptions ───────────────────────────────────────────── 271 289 // Each helper returns an unsubscribe function that tears down both the 272 290 // event listener and the native subscription.
+46 -16
expo/modules/rockbox-now-playing/android/src/main/java/expo/modules/rockboxnowplaying/NowPlayingService.kt
··· 60 60 const val ACTION_SET_PLAYBACK = "expo.modules.rockboxnowplaying.SET_PLAYBACK" 61 61 const val ACTION_SET_COVER_BASE = "expo.modules.rockboxnowplaying.SET_COVER_BASE" 62 62 const val ACTION_CLEAR = "expo.modules.rockboxnowplaying.CLEAR" 63 + /** No-op action used to bring the service into foreground state at app 64 + * launch — keeps the host process alive (and the daemon with it) after 65 + * the user backgrounds the app. The placeholder notification is replaced 66 + * the moment ACTION_UPDATE arrives with real track info. */ 67 + const val ACTION_BOOT = "expo.modules.rockboxnowplaying.BOOT" 63 68 const val ACTION_BUTTON_PLAY = "expo.modules.rockboxnowplaying.PLAY" 64 69 const val ACTION_BUTTON_PAUSE = "expo.modules.rockboxnowplaying.PAUSE" 65 70 const val ACTION_BUTTON_NEXT = "expo.modules.rockboxnowplaying.NEXT" ··· 128 133 } 129 134 130 135 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 136 + // Android 12+: every startForegroundService() must be followed by 137 + // startForeground() within ~5s or the system raises 138 + // ForegroundServiceDidNotStartInTimeException and kills the process. 139 + // CLEAR / SET_COVER_BASE / NEXT / PREV / unknown-action paths used to 140 + // skip refreshNotification() and crash the app — emit a (placeholder) 141 + // notification first thing so the promise is always satisfied. 142 + refreshNotification() 143 + 131 144 val action = intent?.action 132 145 when (action) { 133 146 ACTION_UPDATE -> { ··· 153 166 handleAction("stop") 154 167 stopSelf() 155 168 return START_NOT_STICKY 156 - } 157 - else -> { 158 - // Started without an action (e.g. after process death) — make sure 159 - // we still get into the foreground so Android doesn't kill us. 160 - if (currentTrackId != null) refreshNotification() 161 169 } 162 170 } 163 171 return START_STICKY ··· 428 436 .setDeleteIntent(buildPendingIntent(ACTION_BUTTON_STOP)) 429 437 .build() 430 438 431 - try { 432 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 433 - startForeground( 434 - NOTIFICATION_ID, 435 - notification, 436 - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, 437 - ) 438 - } else { 439 - startForeground(NOTIFICATION_ID, notification) 439 + // Android 14+: only promote to foreground when the app's process state 440 + // permits it. Calling startForeground from a backgrounded process throws 441 + // ForegroundServiceStartNotAllowedException, which the WatchDog escalates 442 + // to a fatal RemoteServiceException — the local try/catch can't save us. 443 + // When we can't promote, just leave the MediaSession active (system Media 444 + // Controls still pick it up); we'll re-attempt the promotion on the next 445 + // intent that arrives while the app is in foreground. 446 + if (canPromoteToForeground()) { 447 + try { 448 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 449 + startForeground( 450 + NOTIFICATION_ID, 451 + notification, 452 + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, 453 + ) 454 + } else { 455 + startForeground(NOTIFICATION_ID, notification) 456 + } 457 + } catch (e: Throwable) { 458 + // Race: importance changed between check and call. Just log. 459 + Log.w(TAG, "startForeground denied (race): ${e.javaClass.simpleName}: ${e.message}") 440 460 } 441 - } catch (e: Throwable) { 442 - Log.e(TAG, "startForeground failed", e) 461 + } else { 462 + Log.i(TAG, "startForeground skipped — app not in foregroundable state") 443 463 } 464 + } 465 + 466 + /** True if the app's process state allows entering the foreground. 467 + * Mirrors the gate in RockboxNowPlayingModule.canStartForegroundService. */ 468 + private fun canPromoteToForeground(): Boolean { 469 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true 470 + val info = android.app.ActivityManager.RunningAppProcessInfo() 471 + android.app.ActivityManager.getMyMemoryState(info) 472 + return info.importance <= 473 + android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE 444 474 } 445 475 446 476 private fun action(intentAction: String, title: String, icon: Int): NotificationCompat.Action =
+65 -1
expo/modules/rockbox-now-playing/android/src/main/java/expo/modules/rockboxnowplaying/RockboxNowPlayingModule.kt
··· 103 103 Unit 104 104 } 105 105 106 + /** 107 + * Bring NowPlayingService into foreground state at app launch so the 108 + * host process (which also owns the embedded rockbox daemon) survives 109 + * Android's background-app reaper. The service stays alive until ACTION_CLEAR 110 + * is sent — the placeholder notification is replaced as soon as a real 111 + * track update arrives. 112 + * 113 + * No-op on iOS / web. 114 + */ 115 + Function("start") { 116 + val ctx = appContext.reactContext?.applicationContext 117 + if (ctx != null) { 118 + ensureNotificationPermission() 119 + val intent = Intent(ctx, NowPlayingService::class.java).apply { 120 + action = NowPlayingService.ACTION_BOOT 121 + } 122 + startServiceCompat(ctx, intent) 123 + } 124 + Unit 125 + } 126 + 106 127 Function("setCoverBaseUrl") { url: String -> 107 128 val ctx = appContext.reactContext?.applicationContext 108 129 if (ctx != null) { ··· 116 137 } 117 138 } 118 139 140 + /** 141 + * Start NowPlayingService in the right mode for the app's current state. 142 + * 143 + * Android 14+ (API 34) blocks `startForegroundService` when the app's 144 + * uidState is SVC / cached / RECEIVER, even with `mediaPlayback` type and 145 + * the FOREGROUND_SERVICE_MEDIA_PLAYBACK permission. The service's later 146 + * `startForeground()` call throws `ForegroundServiceStartNotAllowedException`, 147 + * which the WatchDog escalates to a fatal RemoteServiceException — the 148 + * try/catch in onStartCommand can't save us because the system still 149 + * tracks the unfulfilled FGS-promotion deadline. 150 + * 151 + * Strategy: use `startForegroundService` only when we're actually allowed 152 + * (process is in TOP / FGS / FGS_LOCATION / BFGS importance). Otherwise 153 + * fall back to plain `startService` — the service still receives the 154 + * intent and updates its in-process state, just without the foreground 155 + * promotion that would trigger the violation. The notification will pop 156 + * up the next time the user opens the app and the FGS start succeeds. 157 + */ 119 158 private fun startServiceCompat(ctx: Context, intent: Intent) { 159 + val canStartFgs = canStartForegroundService(ctx) 120 160 try { 121 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 161 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && canStartFgs) { 122 162 ctx.startForegroundService(intent) 123 163 } else { 124 164 ctx.startService(intent) 165 + } 166 + } catch (e: IllegalStateException) { 167 + // Race: importance changed between our check and the call. Fall back. 168 + Log.w(TAG, "startForegroundService denied — falling back to startService: ${e.message}") 169 + try { 170 + ctx.startService(intent) 171 + } catch (e2: Throwable) { 172 + Log.e(TAG, "startService also failed", e2) 125 173 } 126 174 } catch (e: Throwable) { 127 175 Log.e(TAG, "startService failed", e) 128 176 } 177 + } 178 + 179 + /** True if the app's process state lets us start a foreground service. 180 + * Android 14+ requires importance ≥ FOREGROUND_SERVICE; below that we 181 + * must use plain startService. */ 182 + private fun canStartForegroundService(ctx: Context): Boolean { 183 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 184 + // Pre-API-34 the BG-start restriction was much weaker — call FGS 185 + // unconditionally and rely on Service.startForeground's try/catch. 186 + return true 187 + } 188 + val info = android.app.ActivityManager.RunningAppProcessInfo() 189 + android.app.ActivityManager.getMyMemoryState(info) 190 + // VISIBLE/FOREGROUND/PERCEPTIBLE/FOREGROUND_SERVICE all let us promote. 191 + return info.importance <= 192 + android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE 129 193 } 130 194 131 195 /** On Android 13+, the OS silently drops notifications until the user grants
+54
expo/modules/rockbox-rpc/android/src/main/java/expo/modules/rockboxrpc/RockboxRpcModule.kt
··· 125 125 @JvmStatic external fun rb_daemon_port(): Int 126 126 /** 0=stopped, 1=starting, 2=running. */ 127 127 @JvmStatic external fun rb_daemon_state(): Int 128 + /** Force a full library rescan of $ROCKBOX_LIBRARY. Returns 0 if the 129 + * scan was queued, -1 if the daemon isn't running, -38 in remote-only 130 + * builds. The scan runs on a background thread; tail logcat for 131 + * "scan: ..." progress lines. */ 132 + @JvmStatic external fun rb_rescan_library(): Int 128 133 } 129 134 130 135 private val scope = CoroutineScope(Dispatchers.IO) ··· 215 220 } 216 221 Function("chromecastServiceName") { 217 222 rb_chromecast_service_name() ?: "_googlecast._tcp.local." 223 + } 224 + 225 + // "All files access" — required for the in-process scanner to read 226 + // /storage/emulated/0/Music on API 33+. READ_MEDIA_AUDIO doesn't help 227 + // because Rockbox walks the filesystem; only MANAGE_EXTERNAL_STORAGE 228 + // grants raw read() on user paths. JS calls hasAllFilesAccess() at 229 + // startup; if false, requestAllFilesAccess() opens system Settings. 230 + Function("hasAllFilesAccess") { 231 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { 232 + android.os.Environment.isExternalStorageManager() 233 + } else { 234 + // Pre-R: legacy READ_EXTERNAL_STORAGE is sufficient and granted at 235 + // install time on the manifest. Treat as always-allowed. 236 + true 237 + } 238 + } 239 + Function("requestAllFilesAccess") { 240 + val ctx = appContext.reactContext ?: return@Function false 241 + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { 242 + return@Function false 243 + } 244 + val intent = android.content.Intent( 245 + android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, 246 + android.net.Uri.parse("package:${ctx.packageName}"), 247 + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) 248 + try { 249 + ctx.startActivity(intent) 250 + true 251 + } catch (t: Throwable) { 252 + // OEMs without the per-app screen — fall back to the global list. 253 + try { 254 + ctx.startActivity( 255 + android.content.Intent( 256 + android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION, 257 + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), 258 + ) 259 + true 260 + } catch (_: Throwable) { 261 + Log.e("RockboxRpc", "could not open All-files-access settings", t) 262 + false 263 + } 264 + } 265 + } 266 + 267 + // Force a full audio-library rescan against $ROCKBOX_LIBRARY (set to 268 + // /storage/emulated/0/Music by the daemon at boot). Returns the 269 + // native rc — 0 ok, -1 daemon not running, -38 remote-only build. 270 + Function("rescanLibrary") { 271 + rb_rescan_library() 218 272 } 219 273 220 274 Function("setServerUrl") { url: String ->
+10
expo/modules/rockbox-rpc/ios/RockboxRpcModule.swift
··· 129 129 takeString(rb_chromecast_service_name()) ?? "_googlecast._tcp.local." 130 130 } 131 131 132 + // Android-only "All files access" gate. iOS uses NSDocuments / the 133 + // Files app sandbox so there's nothing to grant — always-allowed. 134 + Function("hasAllFilesAccess") { () -> Bool in true } 135 + Function("requestAllFilesAccess") { () -> Bool in false } 136 + 137 + // iOS today builds without the embedded-daemon feature, so there's 138 + // no in-process scanner to retrigger. Return -38 (ENOSYS) so JS can 139 + // surface "remote-only" if needed. 140 + Function("rescanLibrary") { () -> Int32 in -38 } 141 + 132 142 Function("setServerUrl") { (url: String) in 133 143 url.withCString { _ = rb_set_server_url($0) } 134 144 }
+10 -3
expo/modules/rockbox-rpc/scripts/build-android.sh
··· 17 17 mkdir -p "$JNILIBS" 18 18 19 19 cd "$WORKSPACE_ROOT" 20 + 21 + # embedded-daemon links the full C firmware + Rust server into the cdylib 22 + # so the phone can play audio in-process and bind gRPC/HTTP/GraphQL/MPD 23 + # locally. Without it the .so is a thin tonic gRPC client only. 24 + FEATURES=${FEATURES:-embedded-daemon} 25 + 20 26 cargo ndk \ 21 27 -t arm64-v8a \ 22 - -t armeabi-v7a \ 23 - -t x86_64 \ 28 + --platform 26 \ 24 29 -o "$JNILIBS" \ 25 - build -p rockbox-expo $( [[ "$PROFILE" == "release" ]] && echo "--release" ) 30 + build -p rockbox-expo \ 31 + --features "$FEATURES" \ 32 + $( [[ "$PROFILE" == "release" ]] && echo "--release" ) 26 33 27 34 echo 28 35 echo "Built $JNILIBS/<abi>/librockbox_expo.so"
+22
expo/modules/rockbox-rpc/src/index.ts
··· 190 190 rockboxServiceName(): string; 191 191 chromecastServiceName(): string; 192 192 193 + /** 194 + * Android-only: returns true if the app holds MANAGE_EXTERNAL_STORAGE 195 + * ("All files access"), required to scan /storage/emulated/0/Music on 196 + * API 33+. Always true on iOS / pre-Android-11. 197 + */ 198 + hasAllFilesAccess(): boolean; 199 + /** 200 + * Android-only: opens system Settings → "All files access" for this 201 + * package. Returns true if the intent launched. iOS no-ops to false. 202 + * The grant is one-time; check hasAllFilesAccess() after the user returns. 203 + */ 204 + requestAllFilesAccess(): boolean; 205 + 206 + /** 207 + * Force a full library rescan of $ROCKBOX_LIBRARY (the music dir passed 208 + * at daemon boot). Returns 0 if queued, -1 if the daemon isn't running, 209 + * -38 in remote-only builds (no embedded daemon). The scan runs on a 210 + * background thread — listen on the gRPC `ScanCompleted` event or just 211 + * tail logcat ("scan: done, N files") to know when it finishes. 212 + */ 213 + rescanLibrary(): number; 214 + 193 215 // Event API (provided by Expo Modules' EventEmitter base). 194 216 addListener<K extends keyof RockboxRpcEvents>( 195 217 event: K,
+5
firmware/SOURCES
··· 1990 1990 target/hosted/android/cdylib/button-noop.c 1991 1991 target/hosted/android/cdylib/cpuinfo-noop.c 1992 1992 target/hosted/android/cdylib/audiohw-noop.c 1993 + /* Route firmware DEBUGF()/logf()/panicf() to logcat instead of stderr. 1994 + * Stock debug-hosted.c writes to stderr which Android pipes to /dev/null; 1995 + * debug-android.c calls __android_log_print so the buffering / metadata / 1996 + * codec-loader prints we need for diagnosis show up under tag "Rockbox". */ 1997 + target/hosted/android/debug-android.c 1993 1998 #else 1994 1999 /* Existing Java-shell Android app */ 1995 2000 target/hosted/lc-unix.c
+23
firmware/export/config/androidcdylib.h
··· 73 73 * embedded-daemon needs the same. */ 74 74 #define ROCKBOX_SERVER 75 75 76 + /* Sister flag to ROCKBOX_SERVER. apps/SOURCES:314 gates server_thread.c 77 + * and broker_thread.c (which DEFINE server_init() and start_broker()) on 78 + * #ifdef CONFIG_SERVER — a separate macro from ROCKBOX_SERVER, both come 79 + * from configure on desktop (see build-lib/autoconf.h). Without this the 80 + * server_thread.o object never gets built into librockbox.a and the 81 + * cdylib link fails with "cannot locate symbol server_init". */ 82 + #define CONFIG_SERVER 83 + 76 84 /* `sigevent_t` (glibc-style typedef used by kernel-unix.c) is provided as 77 85 * a -D macro in androidcdylibcc rather than typedef'd here — putting code 78 86 * in config.h would leak into the output of `preprocess` (which uses ··· 85 93 * this build, so map it to memcpy directly here. The line starts with # 86 94 * so `preprocess` filters it out of SOURCES expansion. */ 87 95 #define pcm_copy_buffer memcpy 96 + 97 + /* Enable firmware DEBUGF()/LDEBUGF() so the buffering, codec-loader, and 98 + * metadata paths actually log. Together with debug-android.c (added in 99 + * firmware/SOURCES under the CODECS_STATIC block) this routes debugf to 100 + * logcat under tag "Rockbox" via __android_log_print. Without this, every 101 + * DEBUGF call site expands to `do {} while(0)` and we get no firmware 102 + * diagnostics at all on play attempts. 103 + * 104 + * We CAN'T just `#define DEBUG` — libmad treats that as user-asserted 105 + * "build with assertions on" and errors out because we also have NDEBUG 106 + * (libmad enforces XOR). Instead we override DEBUGF directly. The macro 107 + * names are the ones debug.h would have set under DEBUG; pre-defining 108 + * them here makes debug.h's own gating skip its `do{}while(0)` fallback. */ 109 + #define DEBUGF debugf 110 + #define LDEBUGF debugf
+8 -1
firmware/export/debug.h
··· 29 29 extern void ldebugf(const char* file, int line, const char *fmt, ...) 30 30 ATTRIBUTE_PRINTF(3, 4); 31 31 32 - #ifndef CODEC 32 + #ifndef CODEC 33 + 34 + /* If a target's config.h has already pre-defined DEBUGF (e.g. androidcdylib.h 35 + * routes it to a debugf that prints to logcat) keep that definition instead 36 + * of stomping on it with the platform-default block below. */ 37 + #ifndef DEBUGF 33 38 34 39 #if defined(SIMULATOR) && !defined(__PCTOOL__) \ 35 40 || (defined(APPLICATION) && defined(DEBUG)) ··· 54 59 #define LDEBUGF(...) do { } while(0) 55 60 56 61 #endif /* SIMULATOR && !__PCTOOL__ || APPLICATION && DEBUG */ 62 + 63 + #endif /* !DEBUGF */ 57 64 58 65 #endif /* CODEC */ 59 66 #endif
+20 -5
firmware/target/hosted/android/cdylib/pcm-aaudio.c
··· 41 41 #define BYTES_PER_FRAME 4 /* S16_LE stereo */ 42 42 43 43 static AAudioStream *aa_stream = NULL; 44 - static int32_t aa_sample_rate = HW_FREQ_DEFAULT; 44 + /* HW_FREQ_DEFAULT is an INDEX into hw_freq_sampr (not Hz). Until the first 45 + * codec calls set_freq, aa_sample_rate=0 means "let AAudio pick the device 46 + * default rate" — better than opening at "index Hz" (4, 6, …) which AAudio 47 + * silently rejects and falls back to the device rate anyway. set_freq will 48 + * resolve the index to a real rate (44100, 48000, …) on the first call. */ 49 + static int32_t aa_sample_rate = 0; 45 50 46 51 static const void *pcm_data = NULL; 47 52 static size_t pcm_size = 0; ··· 208 213 pthread_mutex_unlock(&aa_mtx); 209 214 } 210 215 211 - static void sink_set_freq(uint16_t freq) 216 + static void sink_set_freq(uint16_t freq_index) 212 217 { 218 + /* `freq_index` is an INDEX into sink->caps.samprs (== hw_freq_sampr), 219 + * NOT a sample rate in Hz. firmware/pcm.c::pcm_set_frequency stores 220 + * the index into pending_freq, then pcm_apply_settings forwards that 221 + * raw index to set_freq. We must look up the actual Hz rate via 222 + * hw_freq_sampr[idx] before passing it to AAudio — otherwise we open 223 + * the stream at "4 Hz" (or whatever the index is), AAudio falls back 224 + * to the device-default rate (48000), and 44100-Hz audio plays at 225 + * 48000 Hz → ~9% faster → chipmunks. Same gotcha as pcm-sdl.c:107. */ 226 + int32_t hz = (int32_t)hw_freq_sampr[freq_index]; 227 + 213 228 pthread_mutex_lock(&aa_mtx); 214 - if ((int32_t)freq == aa_sample_rate && aa_stream) { 229 + if (hz == aa_sample_rate && aa_stream) { 215 230 pthread_mutex_unlock(&aa_mtx); 216 231 return; 217 232 } 218 - LOGI("set_freq %d -> %d", aa_sample_rate, freq); 219 - aa_sample_rate = freq; 233 + LOGI("set_freq idx=%u -> %d Hz (was %d Hz)", freq_index, hz, aa_sample_rate); 234 + aa_sample_rate = hz; 220 235 close_stream(); 221 236 open_stream(aa_sample_rate); 222 237 pthread_mutex_unlock(&aa_mtx);
+84
firmware/target/hosted/android/cdylib/system-android.c
··· 12 12 13 13 #include <pthread.h> 14 14 #include <stdint.h> 15 + #include <stdio.h> 15 16 #include <stdlib.h> 16 17 #include <string.h> 17 18 #include <time.h> ··· 30 31 #define LOGI(fmt, ...) __android_log_print(ANDROID_LOG_INFO, TAG, fmt, ##__VA_ARGS__) 31 32 #define LOGE(fmt, ...) __android_log_print(ANDROID_LOG_ERROR, TAG, fmt, ##__VA_ARGS__) 32 33 34 + /* ── Pipe stdout / stderr into logcat ───────────────────────────────────── 35 + * 36 + * Stock Rockbox C code uses raw printf() / fprintf(stderr, ...) for all the 37 + * `[metadata]`, `[streamfd]`, codec, buffering diagnostics. On Linux those 38 + * land on the controlling terminal. On Android stdout/stderr are wired to 39 + * /dev/null by Zygote so every print silently disappears. 40 + * 41 + * Solution: at process start, redirect both fds to the write end of a pipe 42 + * and spawn a reader thread that __android_log_writes each line. From 43 + * then on every printf in the firmware shows up under tag "Rockbox" in 44 + * logcat — same content the desktop terminal would have shown. */ 45 + static int stdio_pipe[2] = { -1, -1 }; 46 + static pthread_t stdio_thread; 47 + 48 + static void *stdio_logcat_reader(void *arg) 49 + { 50 + (void)arg; 51 + char line[1024]; 52 + size_t fill = 0; 53 + char buf[512]; 54 + ssize_t n; 55 + while ((n = read(stdio_pipe[0], buf, sizeof(buf))) > 0) { 56 + for (ssize_t i = 0; i < n; ++i) { 57 + char c = buf[i]; 58 + if (c == '\n' || fill + 1 >= sizeof(line)) { 59 + line[fill] = 0; 60 + if (fill > 0) 61 + __android_log_write(ANDROID_LOG_INFO, "Rockbox", line); 62 + fill = 0; 63 + if (c != '\n') { 64 + /* line was too long — keep this byte for the next line */ 65 + line[fill++] = c; 66 + } 67 + } else { 68 + line[fill++] = c; 69 + } 70 + } 71 + } 72 + if (fill > 0) { 73 + line[fill] = 0; 74 + __android_log_write(ANDROID_LOG_INFO, "Rockbox", line); 75 + } 76 + return NULL; 77 + } 78 + 79 + static void redirect_stdio_to_logcat(void) 80 + { 81 + static bool installed = false; 82 + if (installed) return; 83 + 84 + /* line-buffer stdout, no buffering on stderr (matches POSIX terminal 85 + * behaviour so [metadata]…\n flushes promptly to logcat). */ 86 + setvbuf(stdout, NULL, _IOLBF, 0); 87 + setvbuf(stderr, NULL, _IONBF, 0); 88 + 89 + if (pipe(stdio_pipe) != 0) { 90 + LOGE("redirect_stdio_to_logcat: pipe() failed: %s", strerror(errno)); 91 + return; 92 + } 93 + if (dup2(stdio_pipe[1], STDOUT_FILENO) < 0 94 + || dup2(stdio_pipe[1], STDERR_FILENO) < 0) { 95 + LOGE("redirect_stdio_to_logcat: dup2 failed: %s", strerror(errno)); 96 + return; 97 + } 98 + 99 + pthread_attr_t attr; 100 + pthread_attr_init(&attr); 101 + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); 102 + if (pthread_create(&stdio_thread, &attr, stdio_logcat_reader, NULL) != 0) { 103 + LOGE("redirect_stdio_to_logcat: pthread_create failed: %s", strerror(errno)); 104 + pthread_attr_destroy(&attr); 105 + return; 106 + } 107 + pthread_attr_destroy(&attr); 108 + 109 + installed = true; 110 + LOGI("redirect_stdio_to_logcat: stdout+stderr now routed to logcat tag Rockbox"); 111 + } 112 + 33 113 /* ── Globals required by the kernel stack accounting ──────────────────── */ 34 114 35 115 uintptr_t *stackbegin; ··· 45 125 46 126 void system_init(void) 47 127 { 128 + /* Wire stdout/stderr to logcat first so any prints emitted during the 129 + * rest of the boot path (codec init, kernel start, etc.) are captured. */ 130 + redirect_stdio_to_logcat(); 131 + 48 132 int marker; 49 133 stackbegin = stackend = (uintptr_t *)&marker; 50 134 quitting = false;
+5 -3
firmware/target/hosted/android/debug-android.c
··· 22 22 23 23 24 24 #include "config.h" 25 - #ifdef DEBUG 25 + 26 + /* Always-on Android debugf shim. The cdylib build pre-defines DEBUGF in 27 + * androidcdylib.h to call debugf so firmware diagnostics surface in 28 + * logcat — `#ifdef DEBUG` gating is unnecessary and would force every 29 + * caller to also define DEBUG (which collides with libmad's NDEBUG check). */ 26 30 #include <android/log.h> 27 31 #include <stdarg.h> 28 32 #include <stdio.h> ··· 49 53 __android_log_vprint(ANDROID_LOG_DEBUG, LOG_TAG, buf, ap); 50 54 va_end(ap); 51 55 } 52 - 53 - #endif
+6 -1
lib/rbcodec/metadata/metadata.h
··· 113 113 #endif /* defined(HAVE_RECORDING) */ 114 114 }; 115 115 116 - #if (CONFIG_PLATFORM & PLATFORM_ANDROID) 116 + /* Java-shell Android dlopen'd per-codec libNAME.so files; the cdylib build 117 + * statically links every codec into one binary and looks them up by their 118 + * canonical "<name>.codec" filenames in lc-android.c's lc_static_table. 119 + * CODECS_STATIC distinguishes the two: keep the libNAME.so convention only 120 + * for the Java-shell path. */ 121 + #if (CONFIG_PLATFORM & PLATFORM_ANDROID) && !defined(CODECS_STATIC) 117 122 #define CODEC_EXTENSION "so" 118 123 #define CODEC_PREFIX "lib" 119 124 #else