From c2fad5ae29e5658e8e8a94fc2f1b6f9bdc070e19 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 11 Jun 2026 16:06:51 -0300 Subject: [PATCH] fix(DexFactory): register injected proxy dex with a single class loader Injecting a generated proxy into the app's PathClassLoader by opening it through a temporary DexClassLoader and splicing its dex element left the same DexFile claimed by two class loaders. ART rejects this unconditionally ("Attempt to register dex file ... with multiple class loaders"), but the second registration only materializes on non-debuggable builds, so release apps crashed on the first runtime-generated proxy while debug builds worked. Build the dex element through the target class loader itself instead, so the DexFile only ever has one owner: on API 24+ via BaseDexClassLoader.addDexPath, below that via DexPathList's static makePathElements/makeDexElements factories spliced into dexElements (the MultiDex technique). If injection fails, fall back to the isolated DexClassLoader path (pre-#1951 behavior) instead of failing the subsequent loadClass with ClassNotFoundException. Adds tests covering the original FragmentFactory scenario: proxies generated at runtime (hidden from the static binding generator) must be resolvable via Class.forName through the app's class loader. Fixes #1962 Refs #1951 --- test-app/app/src/main/assets/app/mainpage.js | 1 + .../app/tests/testClassForNameDiscovery.js | 65 +++++++++++ .../src/main/java/com/tns/DexFactory.java | 110 ++++++++++++------ 3 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testClassForNameDiscovery.js 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); } }