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..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(); @@ -37,7 +48,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) { @@ -47,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 0dba4c64a..538a69739 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,28 @@ 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."); + 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; + } + } } 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..1bf7ee7ed 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -120,13 +120,24 @@ 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; } - 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) { @@ -136,10 +147,10 @@ public Class resolveClass(String baseClassName, String name, String className } String dexFilePath; - if (isInterface) { - dexFilePath = this.generateDex(name, classToProxy, methodOverrides, implementedInterfaces, isInterface); + if (isInterface && !isNamedProxy) { + 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(); @@ -251,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); @@ -274,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/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 { 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": {