Check every Android Java API call against the SDK's API level metadata - #171
Open
ruccho wants to merge 4 commits into
Open
Check every Android Java API call against the SDK's API level metadata#171ruccho wants to merge 4 commits into
ruccho wants to merge 4 commits into
Conversation
MediaCodecInfo.getCanonicalName(), MediaCodecInfo.isHardwareAccelerated(), MediaFormat.getKeys() and MediaFormat.getValueTypeForKey() were all added in API 29, but were called unconditionally. On API 26-28 the resulting NoSuchMethodError was left pending as a JNI exception and terminated the process as soon as control returned to the JVM. print_codec_info() now falls back to MediaCodecInfo.getName() and omits the hardware acceleration flag below API 29. format_to_map() cannot enumerate keys below API 29, so a fallback probes a fixed list of well-known MediaFormat keys with containsKey() and reads each value by trying the typed getters in turn, clearing the ClassCastException raised by a type mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Calling the Android Java API through jni::JNIEnv::call_method names the method and its signature as plain strings. Nothing records which API level a member was introduced in, so a call to a member that does not exist on the minimum supported device compiles and links successfully and only fails at run time — as a NoSuchMethodError that, being left pending as a JNI exception, terminates the process. d767a4e fixed four such calls after the fact. java_api! takes a declaration of the Java members a crate calls and generates typed wrappers for them, checking every declaration at compile time against the platform metadata Android Lint's NewApi check uses. It fails the build when the member does not exist, when it was introduced after min_api without an #[api(N)] annotation, when #[api(N)] disagrees with the level the member was introduced in, when the member has been removed from the platform, or when the declared parameter names do not match the descriptor's arity. A member annotated with #[api(N)] takes an additional ApiLevel<N> parameter, which is only obtainable from ApiLevel::<N>::check(), so the run-time version guard cannot be omitted. The generated wrappers derive their argument and return types from the JNI descriptor and clear any pending JNI exception before propagating the error. Members that throw as part of normal operation are declared #[may_throw] so the exception is discarded without being written to the log. android_api_metadata extracts the classes a crate uses, and their supertypes, from $ANDROID_HOME/platforms/android-*/data/api-versions.xml into a compact text format. Vendoring that subset (21 KB rather than the 5.7 MB original) keeps the build hermetic: no Android SDK, no JDK and no nightly toolchain are needed to compile, and adding a member to a class that is already listed needs no regeneration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Java member unienc_android_mc calls is now declared in src/bindings.rs
and reached through the wrappers java_api! generates for it. No call site
outside that file names a Java method, field or class any more, so a call to a
member that does not exist on the minimum supported API level, or to one
introduced after it without a run-time guard, no longer compiles.
The three members that need a level above the minimum are annotated and carry
an ApiLevel witness through to their call sites:
- MediaCodecInfo.getCanonicalName() and isHardwareAccelerated(), API 29,
with getName() and no acceleration flag as the fallback
- MediaFormat.getKeys() and getValueTypeForKey(), API 29, with the
well-known-key probe as the fallback
- ImageWriter.Builder, API 33, and ImageWriter.newInstance(Surface,int,int),
API 29
Two guards were missing before and are now enforced by the type system:
Image.getHardwareBuffer() requires API 28, and ImageWriter::new reported no
error of its own below API 29 even though every path it takes needs it.
Because the generated wrappers clear the pending JNI exception on failure, a
failed call can no longer terminate the process once control returns to the
JVM. The untyped helpers in java.rs and the error variants that only they
produced are removed; the module now holds JNI-level helpers only.
Verified on a device running Android 16 (API 36) with Unity 6000.3.15f1: both
the API 29 and API 33 paths run, and the exported files are valid H.264 + AAC
MP4s that decode without errors. The API 26-28 fallbacks are unchanged in
behaviour but were not exercised, as no device of that vintage was available.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # InstantReplay.Externals/unienc/crates/unienc_android_mc/Cargo.toml
ruccho
marked this pull request as ready for review
August 19, 2026 06:41
hkmt-mmy
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #168
Problem
unienc_android_mcreaches the Android Java API throughjni::JNIEnv::call_method, which names the method and its signature as plain strings:Nothing in that call records that
MediaFormat.getKeys()was introduced in API 29. The crate is built withcargo ndk --platform 26, so on a device running API 26-28 the call compiles, links, ships, and then throwsNoSuchMethodError.jnireturnsError::JavaExceptionwhile leaving the exception pending, so the process aborts as soon as control returns to the JVM.Four such calls had accumulated —
MediaCodecInfo.getCanonicalName(),MediaCodecInfo.isHardwareAccelerated(),MediaFormat.getKeys()andMediaFormat.getValueTypeForKey(), all API 29. The first commit repairs them. The remaining two commits make the class of bug impossible to reintroduce.Fix
Every Java member the crate calls is now declared in
src/bindings.rs, and thejava_api!macro generates the wrappers:Each declaration is checked at compile time against the metadata Android Lint's
NewApicheck uses ($ANDROID_HOME/platforms/android-*/data/api-versions.xml), which records the API level of every class, method and field on the platform. The build fails whenmin_api(26) and carries no#[api(N)],#[api(N)]disagrees with the level it was introduced in,Removing the annotation from
getKeysproduces exactly the bug this PR started from, now at compile time:A member annotated
#[api(N)]takes an extraApiLevel<N>parameter, obtainable only fromApiLevel::<N>::check(). The run-time version check therefore cannot be forgotten:No call site outside
bindings.rsnames a Java method, field or class any more;JNIEnv::call_method,find_class,get_fieldand friends appear nowhere else in the crate.How it works
Vendoring the subset keeps the build hermetic: compiling needs no Android SDK, no JDK and no nightly toolchain. The subset holds every member of the classes it covers, so adding a call to a member of an already-listed class needs no regeneration; only a new class does. Supertypes are pulled in transitively, so a call that resolves to an inherited member (
ByteBuffer.position(int)onjava.nio.Buffer,HardwareBuffer.close()onAutoCloseable) is found and levelled correctly.The generated wrappers derive their argument and return types from the JNI descriptor, and clear any pending JNI exception before propagating the error — so a failed call can no longer abort the process. Members that throw as part of normal operation (the
MediaFormatgetters used to probe an unknown value type below API 29) are declared#[may_throw], which discards the exception without writing it to the log.Alternatives considered and rejected:
java-spaghettirequires a nightly toolchain (#![feature(arbitrary_self_types)]);jni-bindgenhas been unmaintained since 2022 and stops at API 28;duchessneeds a JDK andandroid.jaronCLASSPATHat build time. None of them model API levels, which is the property this crate actually needs.What changed at the call sites
The three members that need a level above the minimum now carry a witness to their call site:
MediaCodecInfo.getCanonicalName()/isHardwareAccelerated()getName(), acceleration flag omittedMediaFormat.getKeys()/getValueTypeForKey()ImageWriter.BuilderImageWriter.newInstance(Surface,int,int)(29)Two guards were missing before and are now enforced by the type system:
Image.getHardwareBuffer()requires API 28 and was called unconditionally.ImageWriter::newraised no error of its own below API 29, even though every path it takes needs it. It relied entirely onEncodingSystem.is_blit_supported()checking the level for it.The untyped helpers in
java.rs(call_void_method,call_int_method,call_object_method,get_int_field,get_long_field,check_jni_exception) and the four error variants only they produced are removed.java.rsnow holds JNI-level helpers only.common.rsloses roughly 500 lines.Verification
cargo buildforaarch64-linux-androidandx86_64-linux-android, andcargo ndk --platform 26 build --profile release -F unity,mimallocthrough to a linkedlibunienc_c.so: no errors, no warnings.13 unit tests in
java_api_macroscover descriptor parsing, metadata lookup through supertypes, the emitted JNI descriptor, and each of the four rejection cases.Two recordings were exported (10.1 s and 54.8 s). Both are H.264 High 1280x1280 with AAC-LC 24 kHz stereo, and
ffmpeg -f null -decodes both end to end without errors — which also confirms thegetKeys()path feedingMediaMuxer.addTrack()still produces correct track formats, codec-specific data included. An extracted frame shows the scene rendered correctly, so theImageWriter/HardwareBuffer/ Vulkan path is intact. NoNoSuchMethodError, pending JNI exception or Unity-side error appeared in logcat.Limitations