diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 0a8c79b00..4e793a137 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -50,6 +50,7 @@ require("./tests/java-array-test"); require("./tests/field-access-test"); require("./tests/byte-buffer-test"); require("./tests/dex-interface-implementation"); +require("./tests/testClassForNameDiscovery"); require("./tests/testInterfaceImplementation"); require("./tests/testRuntimeImplementedAPIs"); require("./tests/testsInstanceOfOperator"); diff --git a/test-app/app/src/main/assets/app/tests/testClassForNameDiscovery.js b/test-app/app/src/main/assets/app/tests/testClassForNameDiscovery.js new file mode 100644 index 000000000..405c37f0e --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testClassForNameDiscovery.js @@ -0,0 +1,65 @@ +describe("Tests Class.forName discovery of runtime generated classes", function () { + + // Android framework components (e.g. FragmentFactory) resolve classes with + // Class.forName(className, false, context.getClassLoader()). Runtime generated + // proxies must be discoverable through the app's class loader, otherwise + // framework lookups crash with ClassNotFoundException (see issue #1962 / PR #1951). + // + // The extend calls below are built dynamically so the static binding generator + // cannot pre-generate the proxies and DexFactory.resolveClass takes the runtime + // generation + parent class loader injection path. + var ext = "ex" + "tend"; + + // the app's PathClassLoader — the same loader the framework uses, + // e.g. in FragmentFactory.loadFragmentClass via context.getClassLoader() + var appClassLoader = com.tns.Runtime.class.getClassLoader(); + + it("When_extending_a_class_at_runtime_it_should_be_discoverable_through_the_app_class_loader", function () { + var MyObject = java.lang.Object[ext]("ClassForNameDiscoveryObject", { + toString: function () { + return "discoverable"; + } + }); + + var instance = new MyObject(); + var className = instance.getClass().getName(); + + var found = java.lang.Class.forName(className, false, appClassLoader); + + expect(found.getName()).toBe(className); + expect(found.equals(instance.getClass())).toBe(true); + }); + + it("When_implementing_an_interface_at_runtime_it_should_be_discoverable_through_the_app_class_loader", function () { + var MyRunnable = java.lang.Runnable[ext]("ClassForNameDiscoveryRunnable", { + run: function () { + } + }); + + var instance = new MyRunnable(); + var className = instance.getClass().getName(); + + var found = java.lang.Class.forName(className, false, appClassLoader); + + expect(found.getName()).toBe(className); + expect(found.equals(instance.getClass())).toBe(true); + }); + + it("When_a_runtime_generated_class_is_instantiated_through_reflection_it_should_dispatch_to_the_JS_implementation", function () { + var MyObject = java.lang.Object[ext]("ClassForNameDiscoveryInstantiated", { + toString: function () { + return "created via reflection"; + } + }); + + // make sure the implementation object is registered before Java constructs an instance + var instance = new MyObject(); + var className = instance.getClass().getName(); + + // FragmentFactory resolves the class by name and instantiates it through reflection + var found = java.lang.Class.forName(className, false, appClassLoader); + var created = found.getDeclaredConstructor().newInstance(); + + expect(created.toString()).toBe("created via reflection"); + }); +}); 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 1e7b2e8a1..56b37462e 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -1,5 +1,6 @@ package com.tns; +import android.os.Build; import android.util.Log; import com.tns.bindings.AnnotationDescriptor; @@ -20,8 +21,11 @@ import java.io.OutputStreamWriter; import java.lang.reflect.Array; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -166,13 +170,19 @@ public Class resolveClass(String baseClassName, String name, String className } jarFile.setReadOnly(); - Class result; + Class result = null; String classNameToLoad = isInterface ? fullClassName : desiredDexClassName; - if (injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader) { - injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath); - result = classLoader.loadClass(classNameToLoad); - } else { + if (injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader + && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { + try { + result = classLoader.loadClass(classNameToLoad); + } catch (ClassNotFoundException e) { + // fall through to the isolated DexClassLoader below + } + } + + if (result == null) { DexClassLoader dexClassLoader = new DexClassLoader(jarFilePath, this.odexDir.getAbsolutePath(), null, classLoader); result = dexClassLoader.loadClass(classNameToLoad); } @@ -389,40 +399,74 @@ private String getCachedProxyThumb(File proxyDir) { * (e.g. FragmentFactory) use Class.forName() to instantiate classes by name, but * NativeScript's dynamically-generated classes normally live in isolated DexClassLoaders * that Class.forName() doesn't search. + * + * The jar must be added through the target class loader's own DexPathList so that + * the resulting DexFile has the PathClassLoader as its only owner. Opening the jar + * through a separate DexClassLoader first and splicing its dex element would leave + * the same DexFile claimed by two loaders, which ART rejects on non-debuggable + * builds with "Attempt to register dex file ... with multiple class loaders". + * + * @return true if the jar was injected and the class can be loaded through the + * target class loader, false if the caller should fall back to an + * isolated DexClassLoader. */ - private void injectDexIntoClassLoader(BaseDexClassLoader targetClassLoader, String jarFilePath) { + private boolean injectDexIntoClassLoader(BaseDexClassLoader targetClassLoader, String jarFilePath) { try { - // Create a temporary DexClassLoader to produce the optimized dex - DexClassLoader tempLoader = new DexClassLoader(jarFilePath, this.odexDir.getAbsolutePath(), null, targetClassLoader); - - // Get pathList from both classloaders - Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList"); - pathListField.setAccessible(true); - - Object targetPathList = pathListField.get(targetClassLoader); - Object sourcePathList = pathListField.get(tempLoader); + if (Build.VERSION.SDK_INT >= 24) { + // BaseDexClassLoader.addDexPath exists since API 24 + Method addDexPath = BaseDexClassLoader.class.getDeclaredMethod("addDexPath", String.class); + addDexPath.setAccessible(true); + addDexPath.invoke(targetClassLoader, jarFilePath); + } else { + appendDexElements(targetClassLoader, jarFilePath); + } + return true; + } catch (Exception e) { + Log.w("JS", "Failed to inject dex into parent classloader: " + e); + return false; + } + } - // Get dexElements from both pathLists - Field dexElementsField = targetPathList.getClass().getDeclaredField("dexElements"); - dexElementsField.setAccessible(true); + /** + * Pre API 24 equivalent of BaseDexClassLoader.addDexPath: builds the dex elements + * through DexPathList's static factory methods (so no temporary class loader is + * involved) and splices them into the target loader's dexElements array. This is + * the same technique MultiDex used on these OS versions. + */ + private void appendDexElements(BaseDexClassLoader targetClassLoader, String jarFilePath) throws Exception { + Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList"); + pathListField.setAccessible(true); + Object pathList = pathListField.get(targetClassLoader); + + ArrayList files = new ArrayList(); + files.add(new File(jarFilePath)); + ArrayList suppressedExceptions = new ArrayList(); + + Object newElements; + if (Build.VERSION.SDK_INT >= 23) { + Method makePathElements = pathList.getClass().getDeclaredMethod("makePathElements", List.class, File.class, List.class); + makePathElements.setAccessible(true); + newElements = makePathElements.invoke(null, files, this.odexDir, suppressedExceptions); + } else { + Method makeDexElements = pathList.getClass().getDeclaredMethod("makeDexElements", ArrayList.class, File.class, ArrayList.class); + makeDexElements.setAccessible(true); + newElements = makeDexElements.invoke(null, files, this.odexDir, suppressedExceptions); + } - Object targetElements = dexElementsField.get(targetPathList); - Object sourceElements = dexElementsField.get(sourcePathList); + if (!suppressedExceptions.isEmpty()) { + throw suppressedExceptions.get(0); + } - int targetLen = Array.getLength(targetElements); - int sourceLen = Array.getLength(sourceElements); + Field dexElementsField = pathList.getClass().getDeclaredField("dexElements"); + dexElementsField.setAccessible(true); + Object oldElements = dexElementsField.get(pathList); - // Create merged array: target + source - Object merged = Array.newInstance(targetElements.getClass().getComponentType(), targetLen + sourceLen); - System.arraycopy(targetElements, 0, merged, 0, targetLen); - System.arraycopy(sourceElements, 0, merged, targetLen, sourceLen); + int oldLen = Array.getLength(oldElements); + int newLen = Array.getLength(newElements); + Object merged = Array.newInstance(oldElements.getClass().getComponentType(), oldLen + newLen); + System.arraycopy(oldElements, 0, merged, 0, oldLen); + System.arraycopy(newElements, 0, merged, oldLen, newLen); - dexElementsField.set(targetPathList, merged); - } catch (Exception e) { - if (logger.isEnabled()) { - logger.write("Failed to inject dex into parent classloader: " + e.getMessage()); - } - // Non-fatal: class will still be loadable via the ClassStorageService fallback - } + dexElementsField.set(pathList, merged); } }