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
+29 -5
View File
@@ -91,9 +91,12 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
val cmd = mutableListOf(
"$cargoBin/cargo", "ndk",
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
// Link against the minSdk-28 sysroot (libaaudio, API 26, is present). NOTE: this does
// NOT reject an accidental >28 hard import — a cdylib link permits undefined symbols,
// which then fail at System.loadLibrary on every device below the symbol's API level
// (the 0.9.0 Android-≤12 regression). The checkJniImports* task after this build is
// what actually enforces the floor; >28 entry points must be dlsym-resolved (see
// decode::try_set_frame_rate, decode::install_render_callback, adpf).
"--platform", "28",
"-o", file("src/main/jniLibs").absolutePath,
"build", "-p", "punktfunk-client-android",
@@ -102,8 +105,28 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
commandLine(cmd)
}
// Post-link floor check: every undefined symbol in the built .so must exist in the API-28 stubs,
// else System.loadLibrary fails on devices at the minSdk floor (see the script header for the
// 0.9.0 incident this guards against). Runs right after its cargo-ndk task; the APK build depends
// on this task (not the cargo one directly), so a violation fails the build, local and CI alike.
fun registerCheckJniImports(taskName: String, cargoTask: TaskProvider<Exec>) =
tasks.register<Exec>(taskName) {
group = "rust"
description = "verify libpunktfunk_android.so imports stay within the API-28 floor"
dependsOn(cargoTask)
workingDir = repoRoot
commandLine(
"sh", File(repoRoot, "scripts/ci/check-android-jni-imports.sh").absolutePath,
"${androidSdkDir()}/ndk/$ndkVer",
file("src/main/jniLibs").absolutePath,
"28",
)
}
val cargoNdkDebug = registerCargoNdk("cargoNdkDebug", release = false)
val cargoNdkRelease = registerCargoNdk("cargoNdkRelease", release = true)
val checkJniImportsDebug = registerCheckJniImports("checkJniImportsDebug", cargoNdkDebug)
val checkJniImportsRelease = registerCheckJniImports("checkJniImportsRelease", cargoNdkRelease)
afterEvaluate {
// `-PskipRustBuild` skips the cargo-ndk native build — for JVM-only tasks (the Roborazzi
@@ -120,8 +143,9 @@ afterEvaluate {
// debug build); only the cargo profile changes. `-PrustDebug` restores a debug-profile native
// build for the rare session that actually steps through Rust.
if (!project.hasProperty("skipRustBuild")) {
val debugRust = if (project.hasProperty("rustDebug")) cargoNdkDebug else cargoNdkRelease
val debugRust =
if (project.hasProperty("rustDebug")) checkJniImportsDebug else checkJniImportsRelease
tasks.named("preDebugBuild").configure { dependsOn(debugRust) }
tasks.named("preReleaseBuild").configure { dependsOn(cargoNdkRelease) }
tasks.named("preReleaseBuild").configure { dependsOn(checkJniImportsRelease) }
}
}