DexClassLoader, or loading part of an app from the network
I once wrote a small program that downloads precompiled .dex code from a server and runs it. I wanted to find out whether part of an app could live not inside the APK but as code on a server, loaded while the app is running.
The idea came from an article in the Russian magazine Xakep about modules for Android trojans (part 2). Trojans were not the point: I was curious how the mechanism itself works and what Google Play would say about it.
How it works
After a build, an Android app's code sits in .dex files — bytecode for the ART virtual machine. Normally all of them are packed into the APK and loaded by the system class loader. But Android also has public class loaders that accept a file of your own:
DexClassLoaderloads classes from a.dex,.jaror.apkfile on disk;PathClassLoaderdoes the same, and the system uses it for the app itself;InMemoryDexClassLoader(API 26+) loads a.dexstraight from aByteBuffer, with no file at all.
A loaded class is no different from one of your own: you can instantiate it, call its methods and pass it a context. The exception is app components: an Activity or a Service from such code is not declared in the manifest, so the system will not start it.
The code
The cleanest way to connect a plugin to the app is a shared interface: the interface lives in the app, and the class inside the .dex implements it. After loading, the class is cast to the interface and called without reflection.
File dexFile = new File(getCodeCacheDir(), "plugin.dex");if (dexFile.exists()) { dexFile.delete();}
try (InputStream input = new URL(baseUrl + "plugin.dex").openStream(); FileOutputStream output = new FileOutputStream(dexFile)) { // Android 14+: the file must become read-only before anything is written to it dexFile.setReadOnly(); byte[] buffer = new byte[8192]; int count; while ((count = input.read(buffer)) != -1) { output.write(buffer, 0, count); }}
DexClassLoader loader = new DexClassLoader( dexFile.getAbsolutePath(), null, // optimizedDirectory: ignored since API 26 null, // native library path, if the plugin needs one getClassLoader()); // parent: lets the plugin see the app's classes
Class<?> pluginClass = loader.loadClass("com.example.plugin.PluginImpl");Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();plugin.run(getApplicationContext());Without a shared interface, reflection is all that is left — that is what my first version did:
for (Method method : pluginClass.getDeclaredMethods()) { method.setAccessible(true); Log.d(TAG, "method: " + method.getName());}for (Field field : pluginClass.getDeclaredFields()) { field.setAccessible(true); Log.d(TAG, "field: " + field.getName());}Building the .dex
The plugin is compiled with plain javac or kotlinc against the same Plugin interface, and the resulting .class files are converted to .dex with d8 from the Android SDK (the build-tools folder). d8 needs an existing output folder, android.jar from the SDK, and on the classpath the compiled Plugin interface, which stays in the app and does not go into the plugin:
mkdir -p outd8 --lib "$ANDROID_HOME/platforms/android-36/android.jar" --classpath build/classes \ --output out/ build/classes/com/example/plugin/PluginImpl.classout/ will contain classes.dex — that is the file to put on the server.
In a release build the interface also has to survive R8: the plugin is compiled against its original names, and the rule -keep interface com.example.app.Plugin { *; } keeps them.
Limitations
Android 14. If the app targets API 34 or higher, every dynamically loaded file must be read-only, otherwise the system throws an exception when loading it. Mark the file read-only right after opening it, before writing its contents, as in the code above. A file that already exists is better deleted and downloaded again. Details are in the Android 14 behavior changes.
Google Play. In the first version of this note I wrote that once an app has passed review, it can load anything onto the device. That is wrong. Google Play's Device and Network Abuse policy explicitly forbids apps and the SDKs inside them from downloading executable code — .dex, .jar, .so — from any source other than Google Play itself. Review may let such an app through, but it is a policy violation and grounds for removal. The exception is code that runs in a virtual machine or interpreter without direct access to Android APIs, such as JavaScript in a WebView. The sanctioned way to deliver parts of an app on demand is Play Feature Delivery.
Security. Loaded code gets all of the app's permissions. So: HTTPS only, and before loading, verify the file's signature with a public key built into the APK — otherwise a file swapped on the server or in transit turns into someone else's code running inside your app. A hash fetched from the same server proves nothing, since whoever swaps the file swaps the hash too. Google's general advice is to avoid dynamic code loading unless there is a business need.
Where it still makes sense: enterprise apps distributed outside Google Play, plugin systems in internal tools, experiments, and studying how class loading works in ART.