Skip to content

Check every Android Java API call against the SDK's API level metadata - #171

Open
ruccho wants to merge 4 commits into
mainfrom
fix/android-api28-nosuchmethod
Open

Check every Android Java API call against the SDK's API level metadata#171
ruccho wants to merge 4 commits into
mainfrom
fix/android-api28-nosuchmethod

Conversation

@ruccho

@ruccho ruccho commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #168

Problem

unienc_android_mc reaches the Android Java API through jni::JNIEnv::call_method, which names the method and its signature as plain strings:

env.call_method(format, "getKeys", "()Ljava/util/Set;", &[])?

Nothing in that call records that MediaFormat.getKeys() was introduced in API 29. The crate is built with cargo ndk --platform 26, so on a device running API 26-28 the call compiles, links, ships, and then throws NoSuchMethodError. jni returns Error::JavaException while 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() and MediaFormat.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 the java_api! macro generates the wrappers:

class MediaFormat = "android/media/MediaFormat" {
    fn contains_key(key) = "containsKey(Ljava/lang/String;)Z";
    #[api(29)]
    fn get_keys() = "getKeys()Ljava/util/Set;";
}

Each declaration is checked at compile time against the metadata Android Lint's NewApi check 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 when

  • the member does not exist on the platform,
  • it was introduced after min_api (26) and carries no #[api(N)],
  • #[api(N)] disagrees with the level it was introduced in,
  • it has since been removed from the platform, or
  • the declared parameter names do not match the descriptor's arity.

Removing the annotation from getKeys produces exactly the bug this PR started from, now at compile time:

error: `android.media.MediaFormat.getKeys()Ljava/util/Set;` was introduced in API level 29,
       above the minimum supported level 26.
       Annotate the declaration with `#[api(29)]` and guard every call site with
       `ApiLevel::<29>::check()`.
  --> crates/unienc_android_mc/src/bindings.rs:90:25

A member annotated #[api(N)] takes an extra ApiLevel<N> parameter, obtainable only from ApiLevel::<N>::check(). The run-time version check therefore cannot be forgotten:

match ApiLevel::<29>::check()? {
    Some(api) => {
        let name = bindings::MediaCodecInfo::get_canonical_name(env, api, &codec_info)?;
        let accelerated = bindings::MediaCodecInfo::is_hardware_accelerated(env, api, &codec_info)?;
        // ...
    }
    None => {
        let name = bindings::MediaCodecInfo::get_name(env, &codec_info)?;
        // ...
    }
}

No call site outside bindings.rs names a Java method, field or class any more; JNIEnv::call_method, find_class, get_field and friends appear nowhere else in the crate.

How it works

$ANDROID_HOME/.../api-versions.xml  (5.7 MB, all 13470 platform classes)
        |  android_api_metadata: extract the classes in classes.txt + their supertypes
        v
java-api/android-api-versions.txt   (21 KB, committed)
        |  java_api! reads it at macro expansion and checks each declaration
        v
src/bindings.rs  ->  typed wrappers

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) on java.nio.Buffer, HardwareBuffer.close() on AutoCloseable) 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 MediaFormat getters 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-spaghetti requires a nightly toolchain (#![feature(arbitrary_self_types)]); jni-bindgen has been unmaintained since 2022 and stops at API 28; duchess needs a JDK and android.jar on CLASSPATH at 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:

Member Level Fallback below it
MediaCodecInfo.getCanonicalName() / isHardwareAccelerated() 29 getName(), acceleration flag omitted
MediaFormat.getKeys() / getValueTypeForKey() 29 probe of well-known keys
ImageWriter.Builder 33 ImageWriter.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::new raised no error of its own below API 29, even though every path it takes needs it. It relied entirely on EncodingSystem.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.rs now holds JNI-level helpers only. common.rs loses roughly 500 lines.

Verification

  • cargo build for aarch64-linux-android and x86_64-linux-android, and cargo ndk --platform 26 build --profile release -F unity,mimalloc through to a linked libunienc_c.so: no errors, no warnings.

  • 13 unit tests in java_api_macros cover 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 the getKeys() path feeding MediaMuxer.addTrack() still produces correct track formats, codec-specific data included. An extracted frame shows the scene rendered correctly, so the ImageWriter / HardwareBuffer / Vulkan path is intact. No NoSuchMethodError, pending JNI exception or Unity-side error appeared in logcat.

Limitations

  • The API 26-28 fallbacks are unchanged in behaviour but were not exercised on hardware; no device of that vintage was available. They are, however, the paths the type system now forces every call site to provide.

ruccho and others added 4 commits August 18, 2026 17:10
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
ruccho marked this pull request as ready for review August 19, 2026 06:41
@ruccho
ruccho requested a review from hkmt-mmy August 19, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] NoSuchMethodError on API < 29: MediaFormat.getKeys() / MediaCodecInfo.getCanonicalName()

2 participants