Skip to content

Crash on startup when enabling R8 optimization #2039

Description

@BozzaDaniel

Environment
Provide version numbers for the following components (information can be retrieved by running tns info in your project folder or by inspecting the package.json of the project):

  • CLI: 9.1.0
  • Cross-platform modules: @nativescript/core 9.1.0
  • Android Runtime: @nativescript/android 9.1.0 (commit 4e7f2076d0065d1bf157efe18f54dd7d7d73b0f4)
  • iOS Runtime (if applicable): n/a
  • Plugin(s): none — reproduces on a bare ns create --vue app

Also: AGP 8.12.1, Gradle 8.14.3, JDK 21, compileSdk/targetSdk 35, minSdk 24,
build tools 35.0.0, NDK r29. Device: Android emulator, API 35, arm64, 16 KB
pages (sdk_gphone16k_arm64).

Describe the bug
Google will soon require apps to be better optimized (see https://android-developers.googleblog.com/2026/08/app-quality-memory-optimization-secure-onboarding.html). To enable optimization with R8 one can add this to app.gradle:

  buildTypes {
    release {
      minifyEnabled true
      shrinkResources true
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                     "${getAppResourcesPath()}/Android/proguard-rules.pro",
                     metadataKeepRules
    }
  }

However when doing this the app will crash on startup. Using the Opus 5 (1M) model, the following was found when investigating:

With minifyEnabled true, a NativeScript app crashes on cold
start with a fatal JNI abort that names nothing:

Abort message: 'JNI DETECTED ERROR IN APPLICATION: java_class == null
    in call to GetStaticFieldID
    from void com.tns.Runtime.runModule(int, java.lang.String)'
signal 6 (SIGABRT), code -1 (SI_QUEUE)
      #05 pc 00000000006356bc  /apex/com.android.art/lib64/libart.so (art::JNI<false>::GetStaticFieldID(...)+904)
      #06 pc 00000000003225cc  base.apk!libNativeScript.so
      #07 pc 000000000031d8d0  base.apk!libNativeScript.so
      #08 pc 0000000000335658  base.apk!libNativeScript.so

There are two separate problems here. The second is the reason the first is so
expensive.

1. The runtime passes an unchecked FindClass result to JNI

FieldAccessor::GetJavaField uses JEnv::FindClass's return value without
checking it:

// test-app/runtime/src/main/cpp/FieldAccessor.cpp
if (isStatic) {
    fieldData->clazz = env.FindClass(fieldMetadata.getDeclaringType());
    fieldData->fid   = env.GetStaticFieldID(fieldData->clazz, fieldMetadata.name, fieldJniSig);

JEnv::FindClass returns nullptr — with a Java exception left pending
(JEnv.cpp, the m_env->Throw(...) branches) — whenever the class is not in the
dex. GetStaticFieldID(nullptr, ...) is then a fatal abort, and it happens
before the runtime constructs any exception, so neither the class nor the
member name is ever logged. Calling a further JNI function with an exception
already pending is itself illegal, so this is a JNI contract violation
independent of the missing class.

SetJavaField and MethodCache::ResolveMethodSignature guard the identical
pattern with NS_DCHECK, which compiles to nothing in release builds
(NativeScriptAssert.h) — so they are equally fatal in exactly the builds where
this occurs.

The consequence is that the crash destroys the information needed to fix it.
There is no -keep rule you can write for a class whose name you cannot learn.

2. Metadata is generated pre-R8 and nothing tells R8 what it covers

buildMetadata runs against the full compile classpath before R8. Every type in
that metadata is reachable from JavaScript and, typically, from nowhere else — no
bytecode references it — so R8 is free to shrink or rename it, and the runtime
then asks the VM for a name that is no longer there.

Two failure shapes follow, and the second is what makes the usual advice fail:

  • Class removed. A bare ns create --vue app crashes with
    ClassNotFoundException: androidx.activity.OnBackPressedCallback.
  • A type named in a signature renamed. After -keep class androidx.activity.** { *; }
    the dex contains
    OnBackPressedDispatcher.addCallback(Landroidx/lifecycle/t;Landroidx/activity/OnBackPressedCallback;)V
    while the metadata asks for Landroidx/lifecycle/LifecycleOwner;. The
    declaring class was kept and the method was kept — a parameter type was
    renamed, and JNI resolves by full signature.

So keeping a class protects nothing unless every type named in every signature
reachable from JavaScript is kept too. In practice that is the transitive
closure of the metadata, i.e. nearly the whole compile classpath, and
namespace-by-namespace -keep rules cannot converge on it.

To Reproduce

ns create repro --vue --appid com.example.repro
cd repro
npm pkg set dependencies.@nativescript/core=9.1.0
npm pkg set dependencies.@nativescript/android=9.1.0
npm install

App_Resources/Android/app.gradle, inside android { }:

buildTypes {
  release {
    minifyEnabled true
    shrinkResources true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
  }
}

Build a release APK, adb uninstall, install, launch. Crashes on first launch
with ClassNotFoundException: androidx.activity.OnBackPressedCallback.

To reach the unnamed abort — the one this report is really about — add
-keep class androidx.activity.** { *; }, then put a static field read on a
class R8 renames
at the top of app.ts, so it runs inside runModule:

// androidx.lifecycle.Lifecycle$State is renamed: nothing in bytecode names it
const state = (androidx as any).lifecycle.Lifecycle.State.CREATED

That reproduces the abort quoted above with zero third-party dependencies. In our
testing the libNativeScript.so frame offsets (0x3225cc, 0x31d8d0,
0x335658) matched a production app's tombstone exactly, confirming the same
code path.

Expected behavior

  1. A class missing from the final dex must never be a silent native abort. The
    runtime should check FindClass, consume the pending exception, and raise a
    NativeScriptException naming the class and the member — diagnosable from
    JavaScript and actionable with a -keep rule.
  2. minifyEnabled true should either work out of the box, or fail with an error
    that says what to keep. Today a default ns create app cannot be shipped with
    R8 enabled and gives the developer nothing to act on.

Additional context

These were written by Claude (Opus 5) while diagnosing this against the runtime
source at 4e7f2076, built from source and swapped into the app. They work, and
they are offered as evidence of where the problem is — not as a finished
design. We would expect a maintainer to disagree with parts of the second and
third in particular.

(a) Never hand a null jclass to JNI. Adds
JEnv::FindClassOrThrow(className, usage), which clears the pending exception
and throws a NativeScriptException naming the class and member, and routes the
metadata-driven lookups through it (FieldAccessor ×4, MethodCache,
JsArgConverter, JsArgToArrayConverter). The abort becomes:

com.tns.NativeScriptException: Module evaluation promise rejected: .../bundle.mjs —
  Cannot resolve class 'androidx.lifecycle.Lifecycle$State' while reading field 'CREATED'.

This one we would argue for regardless of how the rest is solved. It is small,
it has no downside, and it converts an undebuggable abort into an ordinary
error. The NS_DCHECK guards elsewhere should probably become real checks too,
since they protect nothing in release builds.

(b) Generate the keep rules from the metadata. The metadata already is the
set of types JavaScript can reach, so the generator can emit the rules for it:
-keep class X { *; } for declared types, -keep class X for types named only
in signatures. Wired in via proguardFiles plus
minify*WithR8.dependsOn(buildMetadata) — which is acyclic, since
buildMetadata reads compiled classes and dependency jars and never anything R8
produces. This is roughly what the commented-out line at app/build.gradle:1236
("ensure buildMetadata is done before R8 to allow custom proguard from
metadata") seems to have intended.

Two traps worth knowing, both silent:

  • buildMetadata declares only the three .dat files as outputs, so Gradle
    considers it up-to-date and never regenerates the rules; R8 then reads a stale
    file. The task needs the generated rule file registered as an output.
  • Nested types are separate tree nodes, so a naive join emits
    androidx.lifecycle.Lifecycle.State. ProGuard reads that as class State in
    package …Lifecycle, matches nothing, and R8 strips the members. A mistyped
    keep rule is never an error — only an absent protection.

(c) Ship R8's mapping and translate at lookup time. (b) is correct but
pins every name, so R8 can no longer rename anything — Play's App optimisation
panel reads ~0 for shrinking and optimisation. Regenerating the metadata after
R8 is not the fix: those names are also the names the JS bundle writes, so
post-R8 names would break every call site.

What does work is translating on the way out. The build writes R8's class
mapping beside the metadata (assets/metadata/r8-mapping.dat), the runtime
loads it, and JEnv::FindClass plus the four member-lookup wrappers translate
class names and the Lsome/Type; inside signatures. The metadata and JavaScript
keep talking about androidx.lifecycle.LifecycleOwner while the VM is asked for
androidx.lifecycle.t, and the generated rules can then say
-keep,allowobfuscation.

Translation turned out to be needed in four places, and the last one is worth
raising on its own:

path where
JNI class lookup JEnv::FindClass
JNI signatures the four JEnv member-ID wrappers
runtime binding generation DexFactory.generateDexClass.forName
central class lookup ClassStorageServiceImpl.retrieveClass

Every Java-side resolution funnels through ClassStorageServiceImpl, so one
translation there covers the Java side — except that DexFactory's two
Class.forName calls bypass that service entirely. That looks like an
inconsistency in the runtime independent of this change.

This is the part we are least confident in, and we would rather it prompted a
design discussion than be taken as a patch. Measured on the repro app, 5/5 cold
starts clean:

names pinned with remap
classes renamed 1 251 (30%) 3 985 (94%)
r8.json non-obfuscated 81.89% 76.45%
AAB bytes 43 107 514 43 234 501

94% of classes renamed, but the obfuscation figure moved only ~5 points and the
bundle grew 127 KB, because the mapping asset is 271 KB. Known gaps:

  • It pins member names (-keepclassmembernames) so only a class map has to be
    shipped. Members are the bulk of the identifier mass, so this is what caps the
    gain. Allowing member renaming needs the member mapping and signature-keyed
    lookups — a materially bigger change, and the obvious next step for anyone who
    wants this to score well.
  • com.tns.** is excluded from renaming, because the runtime resolves those by
    hardcoded name from C++ before the mapping has even been read.
  • Getting the mapping into the APK at all is awkward: assets are merged and
    compressed
    (compress<Variant>Assets) before R8 runs, so writing into the
    merged-assets directory from R8's doLast puts the file on disk after the only
    task that would have packaged it — silently, with no warning. It works as a
    generated asset source whose task depends on R8, which then also has to be
    declared to the lint tasks. A first-class place for post-R8 build output would
    be better than either.

On the evidence above we would not claim this pays for itself as an
optimisation yet. It demonstrates that renaming can be supported without
breaking JavaScript; (b) alone ships a smaller bundle for far less machinery.

The honest summary: (a) is a bug fix we would ship as-is. (b) makes R8 usable and
is close to what the repository already gestured at. (c) demonstrates that
renaming can be supported without breaking JavaScript, but the version here
buys only part of the available benefit and deserves a better design than we
gave it.

Smaller notes

  • Metadata filtering (whitelist.mdg / blacklist.mdg) is read from the Gradle
    root, which ns prepare regenerates. A file placed there by hand disappears on
    the next build and the metadata is silently unfiltered. It would help if the
    filters were sourced from App_Resources — or at least if an absent filter
    were logged.
  • An absent whitelist.mdg defaults to *:*, but a present-and-empty one
    replaces that default and allows almost nothing. That asymmetry is a sharp
    edge; an empty whitelist is far more likely to be a mistake than an intent.

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