fix(android): client loads again on Android < 13 — dlsym the API-33 render callback
ci / rust (push) Failing after 41s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 53s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
apple / swift (push) Successful in 1m11s
ci / bench (push) Successful in 6m46s
apple / screenshots (push) Successful in 5m45s
android / android (push) Successful in 12m38s
deb / build-publish (push) Successful in 11m58s
arch / build-publish (push) Successful in 14m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m33s
docker / deploy-docs (push) Successful in 7s

0.9.0's HUD display stage hard-linked AMediaCodec_setOnFrameRenderedCallback via
ndk-sys believing it API 26; the symbol is API 33 ("Available since Android T").
A cdylib links fine with the dangling import, so it only exploded at
System.loadLibrary on every pre-13 device: UnsatisfiedLinkError, then every
NativeBridge touch throws NoClassDefFoundError — surfacing as "Identity
unavailable: io.unom.punktfunk.kit.NativeBridge", a dead pair button, and no
discovery (reported on a Y700 / Android 12; 13+ devices unaffected).

- decode::install_render_callback now dlsym-resolves the entry point from
  libmediandk.so, mirroring try_set_frame_rate; on API < 33 the HUD simply has
  no display stage (the pre-0.9.0 behaviour) and the .so loads.
- New scripts/ci/check-android-jni-imports.sh, wired as checkJniImports* gradle
  tasks the APK build depends on: fails the build if libpunktfunk_android.so
  imports any symbol absent from the NDK's API-28 stubs — `--platform 28` never
  enforced this (cdylib links permit undefined symbols), despite the old
  comment's claim. Verified: 3 ABIs clean at the 28 floor, and the check flags
  the known API-28 symbols when pointed at a 27 floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 10:06:31 +02:00
parent 21be4fc620
commit 8fa12167af
4 changed files with 152 additions and 27 deletions
+13 -8
View File
@@ -43,15 +43,20 @@ tracing = { version = "0.1", default-features = false, features = ["std", "log"]
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
# linked, so the .so still loads on API 28/29.
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; every call above the floor —
# ANativeWindow_setFrameRate (30), …WithChangeStrategy (31), AMediaCodec_setOnFrameRenderedCallback
# (33), the ADPF hints — is dlsym-resolved at runtime (decode::try_set_frame_rate,
# decode::install_render_callback, adpf), never linked, so the .so still loads on API 28+.
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
# Raw FFI for the one AMediaCodec entry point the `ndk` wrapper lacks:
# `AMediaCodec_setOnFrameRenderedCallback` (API 26, under the minSdk-28 floor ⇒ hard-linked) — the
# per-frame render-timestamp callback behind the HUD's `display` stage (see decode::DisplayTracker).
# Reaching it needs the codec's raw pointer, which is why the workspace pins `ndk` to the vendored
# copy whose only patch makes `MediaCodec::as_ptr` public (vendor/ndk, wired in the root Cargo.toml).
# Raw FFI *types* for the one AMediaCodec entry point the `ndk` wrapper lacks:
# `AMediaCodec_setOnFrameRenderedCallback` — the per-frame render-timestamp callback behind the
# HUD's `display` stage (see decode::DisplayTracker). The symbol is API 33 ("Available since
# Android T"), ABOVE the minSdk-28 floor, so it is dlsym-resolved at runtime
# (decode::install_render_callback), NEVER hard-linked: 0.9.0 hard-linked it and
# `System.loadLibrary` failed on every pre-Android-13 device. Only ndk-sys's pointer/typedef
# types are referenced, which creates no import. Reaching it needs the codec's raw pointer, which
# is why the workspace pins `ndk` to the vendored copy whose only patch makes `MediaCodec::as_ptr`
# public (vendor/ndk, wired in the root Cargo.toml).
ndk-sys = { version = "0.6", features = ["media"] }
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2"
+36 -14
View File
@@ -449,27 +449,49 @@ impl DisplayTracker {
}
}
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`, API 26 —
/// under the minSdk-28 floor, so hard-linked via `ndk-sys`; the `ndk` wrapper has no binding, which
/// is what the vendored crate's public `as_ptr` patch is for). Returns the userdata pointer holding
/// a leaked `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with
/// [`release_render_callback`] AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no
/// further callback can fire). `None` (nothing to reclaim) if the platform refused — the HUD then
/// simply has no `display` stage, exactly the pre-callback behaviour.
/// Register [`on_frame_rendered`] on the codec (`AMediaCodec_setOnFrameRenderedCallback`,
/// **API 33** — "Available since Android T" per the NDK header; only the *Java* listener dates
/// back further). That sits above the API-28 floor, so the entry point is dlsym-resolved at
/// runtime like [`try_set_frame_rate`] — hard-linking it (as 0.9.0 shipped) made
/// `System.loadLibrary` fail on every pre-Android-13 device, taking down all of `NativeBridge`.
/// The `ndk` wrapper has no binding and the call needs the raw codec pointer, which is what the
/// vendored crate's public `as_ptr` patch is for. Returns the userdata pointer holding a leaked
/// `Arc<DisplayTracker>` refcount; the caller MUST reclaim it with [`release_render_callback`]
/// AFTER dropping the codec (`AMediaCodec_delete` is what guarantees no further callback can
/// fire). `None` (nothing to reclaim) if the symbol is absent (API < 33) or the platform refused —
/// the HUD then simply has no `display` stage, exactly the pre-callback behaviour.
fn install_render_callback(
codec: &MediaCodec,
tracker: &Arc<DisplayTracker>,
) -> Option<*const DisplayTracker> {
// media_status_t AMediaCodec_setOnFrameRenderedCallback(
// AMediaCodec*, AMediaCodecOnFrameRendered, void*) (API 33)
type SetOnFrameRenderedFn = unsafe extern "C" fn(
*mut ndk_sys::AMediaCodec,
ndk_sys::AMediaCodecOnFrameRendered,
*mut c_void,
) -> ndk_sys::media_status_t;
// SAFETY: `dlopen` of `libmediandk.so`, which the `ndk` media wrapper already links — always
// mapped, so this only bumps its refcount (never closed — process-lifetime handle). `dlsym`
// returns null when the symbol is absent (device below API 33), checked before transmuting the
// non-null pointer to its fn-pointer type.
let set_on_frame_rendered = unsafe {
let lib = libc::dlopen(c"libmediandk.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let sym = libc::dlsym(lib, c"AMediaCodec_setOnFrameRenderedCallback".as_ptr());
if sym.is_null() {
log::info!("decode: no render callback on this API level (<33) — no display stage");
return None;
}
std::mem::transmute::<*mut c_void, SetOnFrameRenderedFn>(sym)
};
let ud = Arc::into_raw(tracker.clone());
// SAFETY: `codec.as_ptr()` is the live codec this thread owns; `ud` outlives the registration
// (reclaimed only after the codec is deleted, per this function's contract).
let status = unsafe {
ndk_sys::AMediaCodec_setOnFrameRenderedCallback(
codec.as_ptr(),
Some(on_frame_rendered),
ud as *mut c_void,
)
};
let status =
unsafe { set_on_frame_rendered(codec.as_ptr(), Some(on_frame_rendered), ud as *mut c_void) };
if status == ndk_sys::media_status_t::AMEDIA_OK {
Some(ud)
} else {