Skip to content

Bundled NDK r27c libc++_shared.so breaks NDK r28+ native libraries (e.g. fbjni 0.8.x) when pickFirst keeps Rive's copy #475

Description

@uDevel

Submission checklist

  • I have confirmed the issue is present in the latest version of rive-android
  • I have searched the documentation and forums and could not find an answer
  • I have searched existing issues and this is not a duplicate

Description

The rive-android AAR ships its own libc++_shared.so, built with NDK r27c. fbjni 0.8.0 and later (released 2026-09-25) is built with NDK r28c. Its libfbjni.so imports __cxa_init_primary_exception, which NDK r27's libc++ doesn't define and r28's does. An APK can hold only one libc++_shared.so per ABI, so an app that depends on both libraries needs pickFirst, as the README's Troubleshooting section suggests. If the copy that pickFirst keeps is Rive's, the app crashes when fbjni loads:

java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "__cxa_init_primary_exception" referenced by "/data/app/~~…/libfbjni.so"...

There's no native crash or signal. dlopen fails, and the UnsatisfiedLinkError is thrown from the System.loadLibrary (or SoLoader) call that loads fbjni. In our app, Flipper loads fbjni at startup in debug builds, so every debug launch crashed. Release builds, which don't include fbjni, work normally.

Expected: adding rive-android next to another library that ships libc++_shared.so shouldn't break that library, or the README should say which copy to keep.

This affects the latest release: 11.14.1's libc++_shared.so is byte-identical to 11.13.0's on all four ABIs.

Evidence. These come from the AARs on Maven Central. The results are the same on arm64-v8a, armeabi-v7a, x86 and x86_64.

# NDK that built each JNI library (decoded from .note.android.ident)
librive-android.so  (rive-android 11.14.1)  Android r27c 12479018
libfbjni.so         (fbjni 0.8.1)           Android r28c 13676358

# Compiler of each bundled libc++_shared.so (llvm-readelf -p .comment, last clang line;
# both files also carry a common "clang version 18.0.1" line)
rive-android 11.14.1  Android (12470979, ... based on r522817c) clang version 18.0.3   # r27c
fbjni 0.8.1           Android (13624864, ... based on r530567e) clang version 19.0.1   # r28c
$ llvm-nm -D --defined-only rive-android-11.14.1/jni/arm64-v8a/libc++_shared.so | grep __cxa_init_primary_exception
$ llvm-nm -D --defined-only fbjni-0.8.1/jni/arm64-v8a/libc++_shared.so | grep __cxa_init_primary_exception
00000000000b7f6c T __cxa_init_primary_exception
$ llvm-nm -D --undefined-only fbjni-0.8.1/jni/arm64-v8a/libfbjni.so | grep __cxa_init_primary_exception
                 U __cxa_init_primary_exception

On every ABI, this is the only symbol libfbjni.so needs that Rive's libc++ lacks. The reverse direction works. fbjni 0.8.1's libc++ exports everything Rive's copy exports, plus 4 more symbols, and every libc++ symbol that librive-android.so imports resolves against it.

Why pickFirst alone can't fix this safely. pickFirst keeps one copy and drops the others. When two AARs both ship a copy, the order of dependencies on the runtime classpath decides which one is kept. A newer libc++_shared.so runs code built with older NDKs (r11 and later), but code built with a newer NDK can need symbols an older copy doesn't have (an NDK maintainer explains this in android/ndk#1639). So the kept copy has to come from the newest NDK used by any library in the APK. Today, an app that combines Rive with another c++_shared library built with r28 or newer has to make sure Rive's copy isn't the one kept, usually by overriding it by hand, and keep that override current as dependencies change. The README's pickFirst advice doesn't mention this.

App-side workaround. Put the newer libc++_shared.so (here, fbjni 0.8.1's) in app/src/main/jniLibs/<abi>/. AGP prefers the app module's own jniLibs over AAR copies, so this copy always wins pickFirst. Rive renders normally on it.

Possible fixes. Any of these would help:

  1. Build with NDK r28c or newer. This is the smallest code change. kotlin/build.gradle.kts already says the 16 KB page-size flag "Can remove when upgrading to r28+". The bump also means changing the r27c check in rive-runtime's build/rive_build_config.lua and the NDK downloads in the CI workflows. Shipping a newer copy should be safe for consumers, because older-built libraries keep working on a newer libc++. For example, every libc++ symbol librive-android.so imports today resolves against r28c's copy. One caveat: a bump keeps the "newest copy must win" rule in place. If an r28-built Rive imports newer symbols, an app that keeps an older r27 copy (for example one from a current React Native release, which is still on r27b) would break Rive instead.

  2. Link libc++ statically (-DANDROID_STL=c++_static) so the AAR no longer ships libc++_shared.so. This removes the conflict for good. The NDK C++ support guide says "JNI libraries distributed with Java AARs must not use the shared runtime to avoid conflicting with other libraries and the app." The middleware vendors guide recommends a single JNI library with the STL linked statically and a version script. Rive already meets those conditions:

    • each ABI has a single librive-android.so
    • since 11.9.2, a version script exports only JNI_OnLoad and Java_app_rive_*
    • it builds with no-exceptions no-rtti

    Rive linked libc++ statically once before (Use static libc++, not shared #69, fixed by moved from using the static lib to the shared #70, whose diff switches to libc++_static despite its title) and switched to c++_shared during the 2023 CMake migration. VerifyNativeReleaseTask and ExtractNativeSymbolsTask currently require libc++_shared.so, so they would need updating.

  3. At minimum, update the README Troubleshooting section. It should say that the kept libc++_shared.so must come from the newest NDK among all dependencies, and name the NDK each release is built with.

Previous working version

None. This isn't a Rive regression. It shows up as soon as an app adds a native library built with r28 (fbjni 0.8.0 and later).

Reproduction steps / code

These steps are distilled from our app, where Flipper loads fbjni at startup.

  1. Set up an app like this:
dependencies {
    implementation("app.rive:rive-android:11.14.1")
    implementation("com.facebook.fbjni:fbjni:0.8.1")
}
android {
    // Without this, the build fails on the duplicate libc++_shared.so
    packaging.jniLibs.pickFirsts += "lib/*/libc++_shared.so"
}
  1. Load fbjni at startup, for example System.loadLibrary("fbjni") in Application.onCreate(). Flipper does the same through SoLoader. You don't need to initialize Rive.
  2. Launch the app. If the APK kept Rive's libc++_shared.so, it crashes with the error above.
    • Dependency order decides which copy is kept. To force Rive's copy, put Rive's jni/<abi>/libc++_shared.so in app/src/main/jniLibs/<abi>/.
    • To see which copy the APK has, run llvm-nm -D --defined-only lib/<abi>/libc++_shared.so | grep __cxa_init_primary_exception. Rive's copy prints nothing and fbjni's prints a T line.

Rive Android runtime version

11.13.0 in our app. 11.14.1 (latest) ships the same libc++_shared.so, byte-identical on all ABIs, as does every release from 11.9.2 on.

Rive API

Compose

Device

Any. The failure is in dynamic-linker symbol resolution, not device-specific. We saw it on every device in our automated test runs.

Device OS

Any (see Device).

App minimum SDK level

28

App NDK level

AGP 9.4.1 default, r28c (28.2.13676358).

Dependencies with native libraries

com.facebook.fbjni:fbjni:0.8.1, built with NDK r28c and shipping its own libc++_shared.so. Flipper loads it at startup, in debug builds only.

Additional context

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions