DexClassLoader, або завантаження частини застосунку з мережі
Колись я написав невелику програму, яка завантажує із сервера заздалегідь скомпільований код у форматі .dex і запускає його. Хотілося з'ясувати, чи може частина застосунку жити не в самому APK, а у вигляді коду на сервері, що підвантажується під час роботи.
Ідея з'явилася після статті в журналі «Хакер» про модулі для Android-троянів (частина 2). Трояни мене не цікавили: цікаво було, як влаштована сама механіка і що на це скаже Google Play.
Як це працює
Код Android-застосунку після збирання лежить у файлах .dex — це байт-код для віртуальної машини ART. Зазвичай усі .dex запаковані в APK, і їх завантажує системний завантажувач класів. Але в Android є й публічні завантажувачі, яким можна передати власний файл:
DexClassLoader— завантажує класи з.dex,.jarабо.apk, що лежить на диску;PathClassLoader— те саме, його система використовує для самого застосунку;InMemoryDexClassLoader(з API 26) — завантажує.dexпросто зByteBuffer, без файлу.
Завантажений клас нічим не відрізняється від «свого»: його можна створити, викликати його методи, передати йому контекст. Виняток — компоненти застосунку: Activity або Service з такого коду не оголошені в маніфесті, тому система їх не запустить.
Код
Плагін найзручніше пов'язати із застосунком спільним інтерфейсом: інтерфейс є в застосунку, а клас у .dex його реалізує. Тоді після завантаження клас приводиться до інтерфейсу й викликається без рефлексії.
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+: файл має стати read-only до того, як у нього щось записано 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: з API 26 ігнорується null, // шлях до нативних бібліотек, якщо вони потрібні getClassLoader()); // батьківський: через нього плагін бачить класи застосунку
Class<?> pluginClass = loader.loadClass("com.example.plugin.PluginImpl");Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();plugin.run(getApplicationContext());Якщо спільного інтерфейсу немає, лишається рефлексія — так було в моєму першому варіанті:
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());}Як зібрати .dex
Плагін компілюється звичайним javac або kotlinc проти того самого інтерфейсу Plugin, а отримані .class перетворюються на .dex утилітою d8 з Android SDK (тека build-tools). d8 потрібні вже наявна тека для результату, android.jar із SDK і в classpath скомпільований інтерфейс Plugin, який залишається в застосунку й у плагін не потрапляє:
mkdir -p outd8 --lib "$ANDROID_HOME/platforms/android-36/android.jar" --classpath build/classes \ --output out/ build/classes/com/example/plugin/PluginImpl.classУ out/ з'явиться classes.dex — саме його треба покласти на сервер.
У релізній збірці інтерфейс має ще пережити R8: плагін скомпільований проти його початкових імен, і зберігає їх правило -keep interface com.example.app.Plugin { *; }.
Обмеження
Android 14. Якщо застосунок націлений на API 34 і вище, кожен динамічно завантажуваний файл має бути доступний лише для читання, інакше система викине виняток під час завантаження. Позначити файл як read-only треба одразу після відкриття, до запису вмісту, як у коді вище. Файл, що вже існує, краще видалити й завантажити знову. Подробиці — у змінах поведінки Android 14.
Google Play. У першій версії цієї нотатки я написав, що після модерації на пристрій можна вантажити що завгодно. Це не так. Правило Google Play Device and Network Abuse прямо забороняє застосункам і вбудованим у них SDK завантажувати виконуваний код — .dex, .jar, .so — з будь-якого джерела, крім самого Google Play. Модерація може пропустити такий застосунок, але це порушення правил і підстава для видалення. Виняток — код, що виконується у віртуальній машині чи інтерпретаторі без прямого доступу до Android API, наприклад JavaScript у WebView. Законний спосіб доставляти частини застосунку на вимогу — Play Feature Delivery.
Безпека. Завантажений код отримує всі права застосунку. Тому лише HTTPS і перевірка підпису файлу до завантаження, з відкритим ключем, вшитим в APK, інакше підміна файлу на сервері чи в мережі перетворюється на виконання чужого коду всередині вашого застосунку. Хеш, завантажений із того самого сервера, нічого не доводить: хто підмінив файл, підмінить і хеш. Google загалом радить уникати динамічного завантаження коду, якщо для нього немає вагомої причини.
Де це все ж доречно: корпоративні застосунки, що поширюються не через Google Play, системи плагінів у внутрішніх інструментах, експерименти й дослідження того, як влаштоване завантаження класів в ART.