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
- 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.
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.generateDex → Class.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.
Environment
Provide version numbers for the following components (information can be retrieved by running
tns infoin your project folder or by inspecting thepackage.jsonof the project):@nativescript/core9.1.0@nativescript/android9.1.0 (commit4e7f2076d0065d1bf157efe18f54dd7d7d73b0f4)ns create --vueappAlso: 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:
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 coldstart with a fatal JNI abort that names nothing:
There are two separate problems here. The second is the reason the first is so
expensive.
1. The runtime passes an unchecked
FindClassresult to JNIFieldAccessor::GetJavaFieldusesJEnv::FindClass's return value withoutchecking it:
JEnv::FindClassreturnsnullptr— with a Java exception left pending(
JEnv.cpp, them_env->Throw(...)branches) — whenever the class is not in thedex.
GetStaticFieldID(nullptr, ...)is then a fatal abort, and it happensbefore 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.
SetJavaFieldandMethodCache::ResolveMethodSignatureguard the identicalpattern with
NS_DCHECK, which compiles to nothing in release builds(
NativeScriptAssert.h) — so they are equally fatal in exactly the builds wherethis occurs.
The consequence is that the crash destroys the information needed to fix it.
There is no
-keeprule you can write for a class whose name you cannot learn.2. Metadata is generated pre-R8 and nothing tells R8 what it covers
buildMetadataruns against the full compile classpath before R8. Every type inthat 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:
ns create --vueapp crashes withClassNotFoundException: androidx.activity.OnBackPressedCallback.-keep class androidx.activity.** { *; }the dex contains
OnBackPressedDispatcher.addCallback(Landroidx/lifecycle/t;Landroidx/activity/OnBackPressedCallback;)Vwhile the metadata asks for
Landroidx/lifecycle/LifecycleOwner;. Thedeclaring 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
-keeprules cannot converge on it.To Reproduce
App_Resources/Android/app.gradle, insideandroid { }:buildTypes { release { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') } }Build a release APK,
adb uninstall, install, launch. Crashes on first launchwith
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 aclass R8 renames at the top of
app.ts, so it runs insiderunModule:That reproduces the abort quoted above with zero third-party dependencies. In our
testing the
libNativeScript.soframe offsets (0x3225cc,0x31d8d0,0x335658) matched a production app's tombstone exactly, confirming the samecode path.
Expected behavior
runtime should check
FindClass, consume the pending exception, and raise aNativeScriptExceptionnaming the class and the member — diagnosable fromJavaScript and actionable with a
-keeprule.minifyEnabled trueshould either work out of the box, or fail with an errorthat says what to keep. Today a default
ns createapp cannot be shipped withR8 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, andthey 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
jclassto JNI. AddsJEnv::FindClassOrThrow(className, usage), which clears the pending exceptionand throws a
NativeScriptExceptionnaming the class and member, and routes themetadata-driven lookups through it (
FieldAccessor×4,MethodCache,JsArgConverter,JsArgToArrayConverter). The abort becomes: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_DCHECKguards 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 Xfor types named onlyin signatures. Wired in via
proguardFilesplusminify*WithR8.dependsOn(buildMetadata)— which is acyclic, sincebuildMetadatareads compiled classes and dependency jars and never anything R8produces. 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:
buildMetadatadeclares only the three.datfiles as outputs, so Gradleconsiders 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.
androidx.lifecycle.Lifecycle.State. ProGuard reads that as classStateinpackage
…Lifecycle, matches nothing, and R8 strips the members. A mistypedkeep 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 runtimeloads it, and
JEnv::FindClassplus the four member-lookup wrappers translateclass names and the
Lsome/Type;inside signatures. The metadata and JavaScriptkeep talking about
androidx.lifecycle.LifecycleOwnerwhile the VM is asked forandroidx.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:
JEnv::FindClassJEnvmember-ID wrappersDexFactory.generateDex→Class.forNameClassStorageServiceImpl.retrieveClassEvery Java-side resolution funnels through
ClassStorageServiceImpl, so onetranslation there covers the Java side — except that
DexFactory's twoClass.forNamecalls bypass that service entirely. That looks like aninconsistency 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:
r8.jsonnon-obfuscated94% 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:
-keepclassmembernames) so only a class map has to beshipped. 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 byhardcoded name from C++ before the mapping has even been read.
compressed (
compress<Variant>Assets) before R8 runs, so writing into themerged-assets directory from R8's
doLastputs the file on disk after the onlytask 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
whitelist.mdg/blacklist.mdg) is read from the Gradleroot, which
ns prepareregenerates. A file placed there by hand disappears onthe 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 filterwere logged.
whitelist.mdgdefaults to*:*, but a present-and-empty onereplaces 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.