Skip to content

Commit c2fad5a

Browse files
committed
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
1 parent bfd7650 commit c2fad5a

3 files changed

Lines changed: 143 additions & 33 deletions

File tree

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ require("./tests/java-array-test");
5050
require("./tests/field-access-test");
5151
require("./tests/byte-buffer-test");
5252
require("./tests/dex-interface-implementation");
53+
require("./tests/testClassForNameDiscovery");
5354
require("./tests/testInterfaceImplementation");
5455
require("./tests/testRuntimeImplementedAPIs");
5556
require("./tests/testsInstanceOfOperator");
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
describe("Tests Class.forName discovery of runtime generated classes", function () {
2+
3+
// Android framework components (e.g. FragmentFactory) resolve classes with
4+
// Class.forName(className, false, context.getClassLoader()). Runtime generated
5+
// proxies must be discoverable through the app's class loader, otherwise
6+
// framework lookups crash with ClassNotFoundException (see issue #1962 / PR #1951).
7+
//
8+
// The extend calls below are built dynamically so the static binding generator
9+
// cannot pre-generate the proxies and DexFactory.resolveClass takes the runtime
10+
// generation + parent class loader injection path.
11+
var ext = "ex" + "tend";
12+
13+
// the app's PathClassLoader — the same loader the framework uses,
14+
// e.g. in FragmentFactory.loadFragmentClass via context.getClassLoader()
15+
var appClassLoader = com.tns.Runtime.class.getClassLoader();
16+
17+
it("When_extending_a_class_at_runtime_it_should_be_discoverable_through_the_app_class_loader", function () {
18+
var MyObject = java.lang.Object[ext]("ClassForNameDiscoveryObject", {
19+
toString: function () {
20+
return "discoverable";
21+
}
22+
});
23+
24+
var instance = new MyObject();
25+
var className = instance.getClass().getName();
26+
27+
var found = java.lang.Class.forName(className, false, appClassLoader);
28+
29+
expect(found.getName()).toBe(className);
30+
expect(found.equals(instance.getClass())).toBe(true);
31+
});
32+
33+
it("When_implementing_an_interface_at_runtime_it_should_be_discoverable_through_the_app_class_loader", function () {
34+
var MyRunnable = java.lang.Runnable[ext]("ClassForNameDiscoveryRunnable", {
35+
run: function () {
36+
}
37+
});
38+
39+
var instance = new MyRunnable();
40+
var className = instance.getClass().getName();
41+
42+
var found = java.lang.Class.forName(className, false, appClassLoader);
43+
44+
expect(found.getName()).toBe(className);
45+
expect(found.equals(instance.getClass())).toBe(true);
46+
});
47+
48+
it("When_a_runtime_generated_class_is_instantiated_through_reflection_it_should_dispatch_to_the_JS_implementation", function () {
49+
var MyObject = java.lang.Object[ext]("ClassForNameDiscoveryInstantiated", {
50+
toString: function () {
51+
return "created via reflection";
52+
}
53+
});
54+
55+
// make sure the implementation object is registered before Java constructs an instance
56+
var instance = new MyObject();
57+
var className = instance.getClass().getName();
58+
59+
// FragmentFactory resolves the class by name and instantiates it through reflection
60+
var found = java.lang.Class.forName(className, false, appClassLoader);
61+
var created = found.getDeclaredConstructor().newInstance();
62+
63+
expect(created.toString()).toBe("created via reflection");
64+
});
65+
});

test-app/runtime/src/main/java/com/tns/DexFactory.java

Lines changed: 77 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.tns;
22

3+
import android.os.Build;
34
import android.util.Log;
45

56
import com.tns.bindings.AnnotationDescriptor;
@@ -20,8 +21,11 @@
2021
import java.io.OutputStreamWriter;
2122
import java.lang.reflect.Array;
2223
import java.lang.reflect.Field;
24+
import java.lang.reflect.Method;
25+
import java.util.ArrayList;
2326
import java.util.HashMap;
2427
import java.util.HashSet;
28+
import java.util.List;
2529
import java.util.zip.ZipEntry;
2630
import java.util.zip.ZipOutputStream;
2731

@@ -166,13 +170,19 @@ public Class<?> resolveClass(String baseClassName, String name, String className
166170
}
167171
jarFile.setReadOnly();
168172

169-
Class<?> result;
173+
Class<?> result = null;
170174
String classNameToLoad = isInterface ? fullClassName : desiredDexClassName;
171175

172-
if (injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader) {
173-
injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath);
174-
result = classLoader.loadClass(classNameToLoad);
175-
} else {
176+
if (injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader
177+
&& injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) {
178+
try {
179+
result = classLoader.loadClass(classNameToLoad);
180+
} catch (ClassNotFoundException e) {
181+
// fall through to the isolated DexClassLoader below
182+
}
183+
}
184+
185+
if (result == null) {
176186
DexClassLoader dexClassLoader = new DexClassLoader(jarFilePath, this.odexDir.getAbsolutePath(), null, classLoader);
177187
result = dexClassLoader.loadClass(classNameToLoad);
178188
}
@@ -389,40 +399,74 @@ private String getCachedProxyThumb(File proxyDir) {
389399
* (e.g. FragmentFactory) use Class.forName() to instantiate classes by name, but
390400
* NativeScript's dynamically-generated classes normally live in isolated DexClassLoaders
391401
* that Class.forName() doesn't search.
402+
*
403+
* The jar must be added through the target class loader's own DexPathList so that
404+
* the resulting DexFile has the PathClassLoader as its only owner. Opening the jar
405+
* through a separate DexClassLoader first and splicing its dex element would leave
406+
* the same DexFile claimed by two loaders, which ART rejects on non-debuggable
407+
* builds with "Attempt to register dex file ... with multiple class loaders".
408+
*
409+
* @return true if the jar was injected and the class can be loaded through the
410+
* target class loader, false if the caller should fall back to an
411+
* isolated DexClassLoader.
392412
*/
393-
private void injectDexIntoClassLoader(BaseDexClassLoader targetClassLoader, String jarFilePath) {
413+
private boolean injectDexIntoClassLoader(BaseDexClassLoader targetClassLoader, String jarFilePath) {
394414
try {
395-
// Create a temporary DexClassLoader to produce the optimized dex
396-
DexClassLoader tempLoader = new DexClassLoader(jarFilePath, this.odexDir.getAbsolutePath(), null, targetClassLoader);
397-
398-
// Get pathList from both classloaders
399-
Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
400-
pathListField.setAccessible(true);
401-
402-
Object targetPathList = pathListField.get(targetClassLoader);
403-
Object sourcePathList = pathListField.get(tempLoader);
415+
if (Build.VERSION.SDK_INT >= 24) {
416+
// BaseDexClassLoader.addDexPath exists since API 24
417+
Method addDexPath = BaseDexClassLoader.class.getDeclaredMethod("addDexPath", String.class);
418+
addDexPath.setAccessible(true);
419+
addDexPath.invoke(targetClassLoader, jarFilePath);
420+
} else {
421+
appendDexElements(targetClassLoader, jarFilePath);
422+
}
423+
return true;
424+
} catch (Exception e) {
425+
Log.w("JS", "Failed to inject dex into parent classloader: " + e);
426+
return false;
427+
}
428+
}
404429

405-
// Get dexElements from both pathLists
406-
Field dexElementsField = targetPathList.getClass().getDeclaredField("dexElements");
407-
dexElementsField.setAccessible(true);
430+
/**
431+
* Pre API 24 equivalent of BaseDexClassLoader.addDexPath: builds the dex elements
432+
* through DexPathList's static factory methods (so no temporary class loader is
433+
* involved) and splices them into the target loader's dexElements array. This is
434+
* the same technique MultiDex used on these OS versions.
435+
*/
436+
private void appendDexElements(BaseDexClassLoader targetClassLoader, String jarFilePath) throws Exception {
437+
Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
438+
pathListField.setAccessible(true);
439+
Object pathList = pathListField.get(targetClassLoader);
440+
441+
ArrayList<File> files = new ArrayList<File>();
442+
files.add(new File(jarFilePath));
443+
ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
444+
445+
Object newElements;
446+
if (Build.VERSION.SDK_INT >= 23) {
447+
Method makePathElements = pathList.getClass().getDeclaredMethod("makePathElements", List.class, File.class, List.class);
448+
makePathElements.setAccessible(true);
449+
newElements = makePathElements.invoke(null, files, this.odexDir, suppressedExceptions);
450+
} else {
451+
Method makeDexElements = pathList.getClass().getDeclaredMethod("makeDexElements", ArrayList.class, File.class, ArrayList.class);
452+
makeDexElements.setAccessible(true);
453+
newElements = makeDexElements.invoke(null, files, this.odexDir, suppressedExceptions);
454+
}
408455

409-
Object targetElements = dexElementsField.get(targetPathList);
410-
Object sourceElements = dexElementsField.get(sourcePathList);
456+
if (!suppressedExceptions.isEmpty()) {
457+
throw suppressedExceptions.get(0);
458+
}
411459

412-
int targetLen = Array.getLength(targetElements);
413-
int sourceLen = Array.getLength(sourceElements);
460+
Field dexElementsField = pathList.getClass().getDeclaredField("dexElements");
461+
dexElementsField.setAccessible(true);
462+
Object oldElements = dexElementsField.get(pathList);
414463

415-
// Create merged array: target + source
416-
Object merged = Array.newInstance(targetElements.getClass().getComponentType(), targetLen + sourceLen);
417-
System.arraycopy(targetElements, 0, merged, 0, targetLen);
418-
System.arraycopy(sourceElements, 0, merged, targetLen, sourceLen);
464+
int oldLen = Array.getLength(oldElements);
465+
int newLen = Array.getLength(newElements);
466+
Object merged = Array.newInstance(oldElements.getClass().getComponentType(), oldLen + newLen);
467+
System.arraycopy(oldElements, 0, merged, 0, oldLen);
468+
System.arraycopy(newElements, 0, merged, oldLen, newLen);
419469

420-
dexElementsField.set(targetPathList, merged);
421-
} catch (Exception e) {
422-
if (logger.isEnabled()) {
423-
logger.write("Failed to inject dex into parent classloader: " + e.getMessage());
424-
}
425-
// Non-fatal: class will still be loadable via the ClassStorageService fallback
426-
}
470+
dexElementsField.set(pathList, merged);
427471
}
428472
}

0 commit comments

Comments
 (0)