From e49ac46267e13b4ab9fd54b8467c4da45a63f660 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 19 Aug 2026 17:56:15 -0700 Subject: [PATCH 1/4] fix(runtime): generate named Java proxies at runtime when not precompiled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Named proxies — Base.extend('a.b.C', {...}) and @JavaProxy('a.b.C') — only existed if the static binding generator had compiled them ahead of time: ClassResolver routed com.tns.gen* names through DexFactory and threw for everything else. SBG scans only assets/app, so a dev server that keeps @nativescript/core off disk (Vite HMR serves it over HTTP) lost every named proxy in core and crashed at boot with LookedUpClassNotFound: Class "com.tns.FragmentClass" not found. - ClassResolver: on LookedUpClassNotFound, fall through to DexFactory and generate the proxy dex at runtime — classes and interfaces alike. Logs a warning per class so SBG coverage gaps stay visible. - DexFactory: tell named proxies apart from anonymous bindings so a dotted interface implementation keeps its requested name instead of the derived com.tns.gen one. Core's two @JavaProxy proxies implement ActivityLifecycleCallbacks and ComponentCallbacks2 — both interfaces. - ProxyGenerator: thumb-suffix dotted proxy file names. Unsuffixed files never matched getDexFile's probe (regenerated every launch), never matched purgeDexesByThumb, and jarFile.exists() reused their stale .jar across app versions. - ModuleInternal: anchor relative runModule paths to the app root in the ES module branch, as the require branch always has — a generated binding's @JavaScriptImplementation carries a project-relative path (./bundle.mjs) that java.io.File flattens, and the ESM branch went straight to the filesystem from the process cwd. Manifest-referenced classes (com.tns.NativeScriptActivity) resolve through this path because the generated dex is injected into the app class loader during entry evaluation — which the boot backstop holds until settled — before the framework instantiates the activity. A precompiled class is still the sturdier answer where a build step can provide one; this makes the missing-class case survivable instead of fatal. --- .../java/com/tns/bindings/ProxyGenerator.java | 7 +++++++ .../runtime/src/main/cpp/ModuleInternal.cpp | 21 ++++++++++++++++--- .../src/main/java/com/tns/ClassResolver.java | 19 ++++++++++++++++- .../src/main/java/com/tns/DexFactory.java | 9 ++++++-- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java index 52506fbd5..55e956eaf 100644 --- a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java +++ b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java @@ -37,7 +37,14 @@ public String generateProxy(String proxyName, ClassDescriptor classToProxy, Hash String proxyFileName; if (proxyName.contains(".")) { + // Thumb-suffix dotted names like the anonymous ones: DexFactory's + // cache probe (getDexFile) and purge (purgeDexesByThumb) both key + // on the thumb, so an unsuffixed file regenerates every launch and + // its stale .jar survives — and gets reused — across app versions. proxyFileName = proxyName; + if (proxyThumb != null) { + proxyFileName += "-" + proxyThumb; + } } else { proxyFileName = classToProxy.getName().replace('$', '_'); if (!isInterface) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index c2b589670..f0fba4213 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -593,6 +593,15 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; @@ -615,9 +624,15 @@ void ModuleInternal::Load(Local context, const string& path) { if (isHttpModule) { TNS_DEBUG(Esm, "run-module http-esm begin %s", NormalizeHttpModuleUrl(path).c_str()); } - // The entry runs before this thread's event loop does, so its graph can - // only make progress from the pump inside LoadESModule. - LoadESModule(isolate, path, BootEntryEvaluationOptions(isHttpModule)); + // Callers reach here with a project-relative name as readily as an + // absolute one: `Runtime.createJSInstance` hands over whatever a + // generated binding's @JavaScriptImplementation carries, and + // java.io.File has already flattened `./bundle.mjs` to `bundle.mjs`. + // The require branch below resolves such a name against the app root; + // the ES module branch goes straight to the filesystem, so anchor it + // here or it fails as "Cannot find module" from whatever the process + // cwd happens to be. + LoadESModule(isolate, AnchorToAppRoot(path, isHttpModule), BootEntryEvaluationOptions(isHttpModule)); if (isHttpModule) { TNS_DEBUG(Esm, "run-module http-esm ok %s", NormalizeHttpModuleUrl(path).c_str()); } diff --git a/test-app/runtime/src/main/java/com/tns/ClassResolver.java b/test-app/runtime/src/main/java/com/tns/ClassResolver.java index 0dba4c64a..33bcb395e 100644 --- a/test-app/runtime/src/main/java/com/tns/ClassResolver.java +++ b/test-app/runtime/src/main/java/com/tns/ClassResolver.java @@ -1,6 +1,9 @@ package com.tns; +import android.util.Log; + import com.tns.system.classes.loading.ClassStorageService; +import com.tns.system.classes.loading.LookedUpClassNotFound; import java.io.IOException; @@ -26,7 +29,21 @@ Class resolveClass(String baseClassName, String fullClassName, DexFactory dex } if (clazz == null) { - clazz = classStorageService.retrieveClass(className); + try { + clazz = classStorageService.retrieveClass(className); + } catch (LookedUpClassNotFound notFound) { + // A named proxy (`Base.extend('a.b.C', {...})` / @JavaProxy) + // whose class the static binding generator never compiled — it + // only scans assets/app, and dev servers keep most source off + // disk. The proxy generator accepts dotted names for classes + // and interfaces alike, so supply the class the same way + // anonymous extends are supplied. + Log.w("JS", "Class " + className + " not precompiled; generating at runtime. Framework references resolve only if dex injection into the app class loader succeeds."); + clazz = dexFactory.resolveClass(canonicalBaseClassName, name, className, methodOverrides, implementedInterfaces, isInterface); + if (clazz == null) { + throw notFound; + } + } } return clazz; diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 345295cab..dcb2c63f5 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -120,9 +120,14 @@ public Class resolveClass(String baseClassName, String name, String className // strip the `com.tns.gen` off the base extended class name String desiredDexClassName = this.getClassToProxyName(fullClassName); + // A named proxy (`Base.extend('a.b.C', {...})` / @JavaProxy) asks for + // exactly that Java class name; the substitutions below are for the + // anonymous form only, where the name is derived from the base. + boolean isNamedProxy = !fullClassName.startsWith(COM_TNS_GEN_PREFIX) && fullClassName.contains("."); + // when interfaces are extended as classes, we still want to preserve // just the interface name without the extra file, line, column information - if (!baseClassName.isEmpty() && isInterface) { + if (!baseClassName.isEmpty() && isInterface && !isNamedProxy) { fullClassName = COM_TNS_GEN_PREFIX + classToProxy; } @@ -136,7 +141,7 @@ public Class resolveClass(String baseClassName, String name, String className } String dexFilePath; - if (isInterface) { + if (isInterface && !isNamedProxy) { dexFilePath = this.generateDex(name, classToProxy, methodOverrides, implementedInterfaces, isInterface); } else { dexFilePath = this.generateDex(desiredDexClassName, classToProxy, methodOverrides, implementedInterfaces, isInterface); From 40dde17955d4762b031d4ce07607e419e6e408f7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 19 Aug 2026 18:14:01 -0700 Subject: [PATCH 2/4] chore: comment clarity --- test-app/runtime/src/main/cpp/ModuleInternal.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index f0fba4213..660c453a4 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -626,12 +626,13 @@ void ModuleInternal::Load(Local context, const string& path) { } // Callers reach here with a project-relative name as readily as an // absolute one: `Runtime.createJSInstance` hands over whatever a - // generated binding's @JavaScriptImplementation carries, and - // java.io.File has already flattened `./bundle.mjs` to `bundle.mjs`. - // The require branch below resolves such a name against the app root; - // the ES module branch goes straight to the filesystem, so anchor it - // here or it fails as "Cannot find module" from whatever the process - // cwd happens to be. + // generated binding's @JavaScriptImplementation carries, and that is + // `./bundle.mjs` by SBG convention — a module name in the app-root + // namespace, not a filesystem path. The require branch below resolves + // such names against the app root; the ES module branch stats the + // string as-is (after CanonicalizeRegistryKey folds `./` away), so + // anchor it here or it resolves against the process cwd and fails as + // "Cannot find module bundle.mjs". LoadESModule(isolate, AnchorToAppRoot(path, isHttpModule), BootEntryEvaluationOptions(isHttpModule)); if (isHttpModule) { TNS_DEBUG(Esm, "run-module http-esm ok %s", NormalizeHttpModuleUrl(path).c_str()); From d3bbd46b300eeecdb1dde5efa66b943f83b786d5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 22:24:05 -0300 Subject: [PATCH 3/4] fix(runtime): resolve entry-module paths in Java before crossing JNI runModule now sends every entry through Module.resolveEntryPath: absolute paths and URLs pass through, and a scheme-less relative name - the @JavaScriptImplementation convention, ./bundle.mjs by SBG's stamp - goes through the same resolution require uses, extension and directory probing included, against the app root. A missing entry throws require's "Failed to find module" from Java instead of a cwd-relative native stat failure. This replaces AnchorToAppRoot. Anchoring inside ModuleInternal::Load resolved the load but not the identity: Runtime::RunModule probes the boot backstop with the path it was handed ("Java resolves package.json's main before handing the path over, so the entry the backstop probes is the very one that was just evaluated"), so an anchored relative entry evaluated under the app-root key while the backstop probed the unanchored one - a registry miss that silently disarmed the hold and the rejection fatal for exactly the dev-served entries this branch exists to support. Resolving before the JNI crossing keeps one resolution seam and makes the evaluated entry and the probed entry the same string by construction. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 22 +++---------------- .../runtime/src/main/java/com/tns/Module.java | 18 +++++++++++++++ .../src/main/java/com/tns/Runtime.java | 3 +-- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 660c453a4..c2b589670 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -593,15 +593,6 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; @@ -624,16 +615,9 @@ void ModuleInternal::Load(Local context, const string& path) { if (isHttpModule) { TNS_DEBUG(Esm, "run-module http-esm begin %s", NormalizeHttpModuleUrl(path).c_str()); } - // Callers reach here with a project-relative name as readily as an - // absolute one: `Runtime.createJSInstance` hands over whatever a - // generated binding's @JavaScriptImplementation carries, and that is - // `./bundle.mjs` by SBG convention — a module name in the app-root - // namespace, not a filesystem path. The require branch below resolves - // such names against the app root; the ES module branch stats the - // string as-is (after CanonicalizeRegistryKey folds `./` away), so - // anchor it here or it resolves against the process cwd and fails as - // "Cannot find module bundle.mjs". - LoadESModule(isolate, AnchorToAppRoot(path, isHttpModule), BootEntryEvaluationOptions(isHttpModule)); + // The entry runs before this thread's event loop does, so its graph can + // only make progress from the pump inside LoadESModule. + LoadESModule(isolate, path, BootEntryEvaluationOptions(isHttpModule)); if (isHttpModule) { TNS_DEBUG(Esm, "run-module http-esm ok %s", NormalizeHttpModuleUrl(path).c_str()); } diff --git a/test-app/runtime/src/main/java/com/tns/Module.java b/test-app/runtime/src/main/java/com/tns/Module.java index b74020762..29317d3bd 100644 --- a/test-app/runtime/src/main/java/com/tns/Module.java +++ b/test-app/runtime/src/main/java/com/tns/Module.java @@ -47,6 +47,24 @@ static String getApplicationFilesPath() { return ApplicationFilesPath; } + /** + * Resolves an entry-module path for runModule. A non-absolute, scheme-less + * name is an app-root-relative module name (the @JavaScriptImplementation + * convention) and goes through the same resolution require uses - extension + * and directory probing included - so the native side only ever receives a + * loadable identity: an absolute path or a URL. Missing entries throw here, + * with require's wording, instead of failing against the process cwd. + */ + static String resolveEntryPath(String path) { + if (path.isEmpty() || path.startsWith("/") || path.contains("://")) { + return path; + } + String relative = (path.startsWith("./") || path.startsWith("../") || path.startsWith("~/")) + ? path + : "./" + path; + return resolvePath(relative, ApplicationFilesPath + ModulesFilesPath); + } + @RuntimeCallable private static String resolvePath(String path, String baseDir) { // The baseDir is the directory path of the calling module. diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index e1947e1fc..b21263154 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -696,8 +696,7 @@ public void run() throws NativeScriptException { } public void runModule(File jsFile) throws NativeScriptException { - String filePath = jsFile.getPath(); - runModule(getRuntimeId(), filePath); + runModule(getRuntimeId(), Module.resolveEntryPath(jsFile.getPath())); } public Object runScript(File jsFile) throws NativeScriptException { From 8eb0b8682c6026a39187ecf15dc08b67958995f6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 22:40:28 -0300 Subject: [PATCH 4/4] fix(runtime): content-keyed proxy dex cache; keep the not-found error primary The dex cache key was name + thumb, and the thumb only changes on reinstall - so once the thumb suffix made the cache actually hit, an HMR edit to a proxy's method overrides would silently keep loading the previous dex. The file name now also carries a digest of everything that shapes the generated proxy besides its name: base class, interface flag, and the sorted override and interface lists, so JS enumeration order cannot cause a spurious miss. The digest sits after the thumb, so the thumb-keyed purge keeps matching old generations. When runtime generation itself fails, ClassResolver now rethrows the original LookedUpClassNotFound with the generation failure attached as a suppressed exception, instead of letting a downstream ClassNotFound or ASM error replace the one message that names the class the app actually asked for. --- .../java/com/tns/bindings/ProxyGenerator.java | 15 +++++ .../src/main/java/com/tns/ClassResolver.java | 9 ++- .../src/main/java/com/tns/DexFactory.java | 55 +++++++++++++++++-- test-app/tools/package-lock.json | 1 + 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java index 55e956eaf..1cbfc8b0f 100644 --- a/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java +++ b/test-app/runtime-binding-generator/src/main/java/com/tns/bindings/ProxyGenerator.java @@ -26,6 +26,17 @@ public void setProxyThumb(String proxyThumb) { } public String generateProxy(String proxyName, ClassDescriptor classToProxy, HashSet methodOverrides, HashSet implementedInterfaces, boolean isInterface, AnnotationDescriptor[] annotations) throws IOException { + return generateProxy(proxyName, null, classToProxy, methodOverrides, implementedInterfaces, isInterface, annotations); + } + + /** + * cacheDigest, when present, becomes part of the proxy's file name. The + * thumb only changes on reinstall, so name + thumb alone cannot see an + * edit to the proxy's contents (method overrides, interfaces) - the + * digest is what makes such an edit miss the cache instead of silently + * loading the previous dex. + */ + public String generateProxy(String proxyName, String cacheDigest, ClassDescriptor classToProxy, HashSet methodOverrides, HashSet implementedInterfaces, boolean isInterface, AnnotationDescriptor[] annotations) throws IOException { ApplicationWriter aw = new ApplicationWriter(); aw.visit(); @@ -54,6 +65,10 @@ public String generateProxy(String proxyName, ClassDescriptor classToProxy, Hash proxyFileName += "-" + proxyThumb; } } + // After the thumb, so purgeDexesByThumb keeps matching old generations. + if (cacheDigest != null) { + proxyFileName += "-" + cacheDigest; + } if (IsLogEnabled) { System.out.println("Generator: Saving proxy with file name: " + proxyFileName); diff --git a/test-app/runtime/src/main/java/com/tns/ClassResolver.java b/test-app/runtime/src/main/java/com/tns/ClassResolver.java index 33bcb395e..538a69739 100644 --- a/test-app/runtime/src/main/java/com/tns/ClassResolver.java +++ b/test-app/runtime/src/main/java/com/tns/ClassResolver.java @@ -39,7 +39,14 @@ Class resolveClass(String baseClassName, String fullClassName, DexFactory dex // and interfaces alike, so supply the class the same way // anonymous extends are supplied. Log.w("JS", "Class " + className + " not precompiled; generating at runtime. Framework references resolve only if dex injection into the app class loader succeeds."); - clazz = dexFactory.resolveClass(canonicalBaseClassName, name, className, methodOverrides, implementedInterfaces, isInterface); + try { + clazz = dexFactory.resolveClass(canonicalBaseClassName, name, className, methodOverrides, implementedInterfaces, isInterface); + } catch (Throwable generationFailure) { + // The precise not-found is the actionable error; a failed + // generation attempt is its detail, not its replacement. + notFound.addSuppressed(generationFailure); + throw notFound; + } if (clazz == null) { throw notFound; } diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index dcb2c63f5..1bf7ee7ed 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -131,7 +131,13 @@ public Class resolveClass(String baseClassName, String name, String className fullClassName = COM_TNS_GEN_PREFIX + classToProxy; } - File dexFile = this.getDexFile(desiredDexClassName); + // The thumb only changes on reinstall, so a cache key of name + thumb + // cannot see an edit to the proxy's contents: under HMR a named + // proxy's new method overrides would silently load the previous dex. + // The digest carries the contents into the file name. + String contentDigest = computeContentDigest(classToProxy, methodOverrides, implementedInterfaces, isInterface); + + File dexFile = this.getDexFile(desiredDexClassName, contentDigest); // generate dex file if (dexFile == null) { @@ -142,9 +148,9 @@ public Class resolveClass(String baseClassName, String name, String className String dexFilePath; if (isInterface && !isNamedProxy) { - dexFilePath = this.generateDex(name, classToProxy, methodOverrides, implementedInterfaces, isInterface); + dexFilePath = this.generateDex(name, contentDigest, classToProxy, methodOverrides, implementedInterfaces, isInterface); } else { - dexFilePath = this.generateDex(desiredDexClassName, classToProxy, methodOverrides, implementedInterfaces, isInterface); + dexFilePath = this.generateDex(desiredDexClassName, contentDigest, classToProxy, methodOverrides, implementedInterfaces, isInterface); } dexFile = new File(dexFilePath); long stopGenTime = System.nanoTime(); @@ -256,12 +262,51 @@ private String getClassToProxyName(String className) throws InvalidClassExceptio return classToProxy; } - private File getDexFile(String className) throws InvalidClassException { + /** + * Digest of everything that shapes the generated proxy besides its name, + * so the dex cache key changes when the proxy's contents do. Sorted, so + * JS property-enumeration order cannot produce a spurious miss. + */ + private static String computeContentDigest(String classToProxy, String[] methodOverrides, String[] implementedInterfaces, boolean isInterface) { + StringBuilder canonical = new StringBuilder(classToProxy).append('\n').append(isInterface); + if (methodOverrides != null) { + String[] sortedOverrides = methodOverrides.clone(); + java.util.Arrays.sort(sortedOverrides); + for (String override : sortedOverrides) { + canonical.append('\n').append(override); + } + } + if (implementedInterfaces != null) { + String[] sortedInterfaces = implementedInterfaces.clone(); + java.util.Arrays.sort(sortedInterfaces); + for (String iface : sortedInterfaces) { + canonical.append('').append(iface); + } + } + try { + byte[] hash = java.security.MessageDigest.getInstance("SHA-256") + .digest(canonical.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(10); + for (int i = 0; i < 5; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + // SHA-256 is mandatory on Android; a digestless key only loses + // cache freshness, never correctness of a fresh generation. + return null; + } + } + + private File getDexFile(String className, String contentDigest) throws InvalidClassException { String classToProxyFile = className.replace("$", "_"); if (this.dexThumb != null) { classToProxyFile += "-" + this.dexThumb; } + if (contentDigest != null) { + classToProxyFile += "-" + contentDigest; + } String dexFilePath = dexDir + "/" + classToProxyFile + ".dex"; File dexFile = new File(dexFilePath); @@ -279,7 +324,7 @@ private File getDexFile(String className) throws InvalidClassException { return null; } - private String generateDex(String proxyName, String className, String[] methodOverrides, String[] implementedInterfaces, boolean isInterface) throws ClassNotFoundException, IOException { + private String generateDex(String proxyName, String contentDigest, String className, String[] methodOverrides, String[] implementedInterfaces, boolean isInterface) throws ClassNotFoundException, IOException { Class classToProxy = Class.forName(className); HashSet methodOverridesSet = null; diff --git a/test-app/tools/package-lock.json b/test-app/tools/package-lock.json index dec6bc4c5..1f8c782e0 100644 --- a/test-app/tools/package-lock.json +++ b/test-app/tools/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "static_analysis", "version": "1.0.0", "license": "ISC", "dependencies": {