Build a Runtime Flutter APK Loader

Dynamically load and run Flutter APKs on Android using DexClassLoader.

Introduction

30 Second Summary

When you tap a Flutter app on your phone, Android quietly unpacks native libraries, loads compiled Dart code, and boots an entire engine before a single pixel renders. Most developers never see this machinery.

In this project, you will build Flutter Explorer, a pure Kotlin Android app that loads and runs any Flutter APK at runtime without installing it. You'll extract native libraries, dynamically load classes, and render a Flutter UI inside your own custom container.

What You'll Build

A host Android application that uses DexClassLoader and System.load() to dynamically extract, load, and run Flutter APKs from device storage.

By the end of this project, you'll have:

  • An APK extraction engine that parses Flutter APKs and extracts native .so libraries and flutter_assets to the device filesystem.
  • A dynamic class loader and native library pre-loader that boots the Flutter engine from an external APK using reflection and ordered System.load() calls.
  • A container Activity with a Material toolbar and embedded FlutterView that renders the dynamically loaded Flutter app with full lifecycle management.
  • Secret Mission: A file picker UI with a grid of previously loaded apps for browsing and launching multiple Flutter APKs from device storage.

Are there any prerequisites?

This is an advanced project. You should be comfortable with basic Kotlin syntax, understand the Android Activity lifecycle, and be familiar with running commands in a terminal.

You also need an existing Android Studio installation (for the Android SDK) and either a physical Android device or an emulator for testing.

Scaffold the Android Project

Your environment is confirmed working. Now you need an actual Android project to build Flutter Explorer in.

Gradle's init command has no Android template, so you'll create the project structure by hand. This gives you full control over the build configuration and keeps the project free of boilerplate you don't need.

In this step, get ready to:
  • Create the project directory and generate the Gradle wrapper.
  • Write the Gradle configuration files for a multi-module Android project.
  • Add the Android manifest and a placeholder main Activity.
Create the project directory and generate the Gradle wrapper

The Gradle wrapper is a set of scripts that download and run a specific Gradle version. This ensures every build uses the same Gradle version regardless of what's installed on the machine.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir FlutterExplorer
cd FlutterExplorer
  • Generate the Gradle wrapper pinned to version 8.4 by running:
gradle wrapper --gradle-version 8.4

What does this command do?

This creates gradlew and gradlew.bat scripts plus a gradle/wrapper/ directory. From now on, you'll use ./gradlew instead of the system gradle command for all builds.

  • Confirm the wrapper files were created by running:
ls

You should see gradlew, gradlew.bat, and a gradle/ directory in the output.

Seeing 'gradle: command not found'?

Android Studio bundles Gradle, but it may not be on your PATH. Add it manually by locating your Android Studio Gradle installation (typically at ~/.gradle/wrapper/dists/ or inside Android Studio's installation folder) and adding its bin/ directory to your PATH.

Alternatively, install Gradle via your package manager (e.g. pacman -S gradle on Arch Linux).

help me fix 'gradle: command not found' on Linux

  • Open the FlutterExplorer folder in VS Code. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows/Linux) to open the Command Palette.
  • Type File: Open Folder and press Enter.
  • Navigate to Desktop/FlutterExplorer and open it.
Write the Gradle configuration files

An Android project uses a multi-module Gradle structure. The root defines which plugins are available, and the app module defines how to build the actual APK.

  • Create a new file called settings.gradle.kts in the project root (the FlutterExplorer folder) and paste the following code:
pluginManagement {
    repositories {
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "FlutterExplorer"
include(":app")

What does this code do?

pluginManagement tells Gradle where to find build plugins (like the Android Gradle Plugin). dependencyResolutionManagement tells it where to find library dependencies.

include(":app") declares a submodule named app that will contain your Android source code.

  • Save settings.gradle.kts.
  • Create another new file called build.gradle.kts in the same project root folder and paste this:
plugins {
    id("com.android.application") version "8.4.0" apply false
    id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

What does this code do?

This declares the Android Gradle Plugin (8.4.0) and the Kotlin Android plugin (1.9.24) as available plugins. The apply false means they are declared at root level but only applied in the submodules that actually need them.

  • Save build.gradle.kts.

Now create the app module's build file. This is where most of the Android-specific configuration lives.

  • Create the app/ directory. In VS Code's terminal (open it with Ctrl+`), run:
mkdir -p app
  • Create a new file at app/build.gradle.kts and paste the following:
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.flutterexplorer.app"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.flutterexplorer.app"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.activity:activity-ktx:1.8.2")
}

What does this code do?

compileSdk = 34 tells Gradle to compile against Android 14 APIs. minSdk = 24 sets Android 7.0 as the minimum supported version, which gives access to the DexClassLoader features you'll need later.

The three dependencies pull in AndroidX AppCompat for backward-compatible Activity support, Material Design components for the toolbar UI, and the Activity KTX library for modern lifecycle utilities.

  • Save app/build.gradle.kts.
Add the Android manifest and main Activity

Every Android app needs an AndroidManifest.xml that declares its activities and permissions. You also need at least one Activity class for the app to launch.

  • Create the full source directory structure by running this in the terminal:
mkdir -p app/src/main/kotlin/com/flutterexplorer/app
  • Create a new file at app/src/main/AndroidManifest.xml and paste the following:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

    <application
        android:allowBackup="true"
        android:label="Flutter Explorer"
        android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity
            android:name=".FlutterContainerActivity"
            android:exported="false"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize" />
    </application>
</manifest>

What does this code do?

The manifest declares two activities. MainActivity is the launcher (the entry point when you tap the app icon). FlutterContainerActivity will host the dynamically loaded Flutter UI later.

The configChanges attribute on the container activity prevents Android from destroying and recreating it during rotation or locale changes. Flutter handles those internally.

READ_EXTERNAL_STORAGE is needed so the app can read Flutter APK files from the device's Downloads folder.

  • Save AndroidManifest.xml.
  • Create a new file at app/src/main/kotlin/com/flutterexplorer/app/MainActivity.kt and paste this placeholder:
package com.flutterexplorer.app

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}

What does this code do?

This is the minimum viable Activity. It extends AppCompatActivity for backward-compatible theming support. You'll flesh out the UI in a later step.

  • Save MainActivity.kt.
  • Verify the project compiles successfully by running this in the terminal:
./gradlew assembleDebug

The first run downloads Gradle 8.4 and the Android SDK dependencies. This may take a few minutes. You should see BUILD SUCCESSFUL at the end.

Build failed?

If you see an error about SDK licenses not accepted, run yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses to accept them.

If you see a JDK version mismatch, confirm java -version shows 17 or higher. The build requires JDK 17+ because of the jvmTarget = "17" setting.

help me debug my FlutterExplorer Gradle build failure

✔️ Awesome, I've got everything!

Great. Double check you've saved all five files before moving on.

ⓧ I'd like to double check the full code

Here are all five files for reference.

pluginManagement {
    repositories {
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "FlutterExplorer"
include(":app")
plugins {
    id("com.android.application") version "8.4.0" apply false
    id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.flutterexplorer.app"
    compileSdk = 34

    defaultConfig {
        applicationId = "com.flutterexplorer.app"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation("com.google.android.material:material:1.11.0")
    implementation("androidx.activity:activity-ktx:1.8.2")
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

    <application
        android:allowBackup="true"
        android:label="Flutter Explorer"
        android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity
            android:name=".FlutterContainerActivity"
            android:exported="false"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize" />
    </application>
</manifest>
package com.flutterexplorer.app

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}

Why scaffold by hand instead of using Android Studio?

Android Studio's new project wizard generates dozens of files (theme resources, test configs, Compose dependencies) that you don't need for a pure Kotlin host app. Scaffolding manually keeps the project minimal and makes every file intentional.

More importantly, the host app must NOT use Flutter itself. If it bundled its own libflutter.so, it would conflict with the target APK's engine library at runtime.

Your Android project skeleton is ready. Next up, you'll build the APK extraction engine that tears apart Flutter APKs to get at their native libraries and assets.

Build the APK Extraction Engine

Your project scaffold is in place and compiling. Now you need a way to crack open a Flutter APK and pull out the pieces that make it tick.

A Flutter APK is a ZIP archive containing compiled Dart code (libapp.so), the Flutter rendering engine (libflutter.so), Java/Kotlin embedding classes (classes.dex), and runtime assets (flutter_assets/). In this step, you will build an extraction utility that unpacks these layers into a directory your app controls.

In this step, get ready to:
  • Create an ApkExtractor class that reads APK contents using ZipInputStream.
  • Detect the device's CPU architecture and extract only matching native libraries.
  • Extract flutter_assets to the app's private storage for later engine use.
Create the ApkExtractor class structure

The extractor needs to know where to write files and which CPU architecture to target. You will set up the class with a data class that holds extraction results, then prepare the output directories.

What is an ABI?

ABI stands for Application Binary Interface. Android devices use different CPU architectures like arm64-v8a (most modern phones), armeabi-v7a (older 32-bit), or x86_64 (emulators).

A Flutter APK bundles separate .so native libraries for each architecture inside lib/<abi>/ folders. Your extractor only needs to pull out the set matching the device it is running on.

  • Create a new file called ApkExtractor.kt inside app/src/main/kotlin/com/flutterexplorer/app/.
  • Add the class structure, data class, and directory setup by pasting this code:
package com.flutterexplorer.app

import android.content.Context
import android.os.Build
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream

class ApkExtractor(private val context: Context) {

    companion object {
        private const val TAG = "ApkExtractor"
    }

    // Holds the paths to everything extracted from the APK
    data class ExtractionResult(
        val nativeLibDir: File,
        val flutterAssetsDir: File,
        val soFiles: List<File>,
        val apkPath: String
    )

    fun extract(apkFile: File): ExtractionResult {
        // Detect the device's primary CPU architecture
        val targetAbi = Build.SUPPORTED_ABIS[0]
        Log.d(TAG, "Device ABI: $targetAbi")

        // Create a fresh extraction directory in app-private storage
        val extractDir = File(context.filesDir, "extracted_${apkFile.nameWithoutExtension}")
        if (extractDir.exists()) extractDir.deleteRecursively()
        extractDir.mkdirs()

        // Subdirectory for native .so libraries
        val nativeLibDir = File(extractDir, "lib")
        nativeLibDir.mkdirs()

        // Subdirectory for Flutter runtime assets
        val flutterAssetsDir = File(extractDir, "flutter_assets")
        flutterAssetsDir.mkdirs()

        val soFiles = mutableListOf<File>()

What does this code do?

The ExtractionResult data class gives callers a clean handle to everything extracted: the directory of native libraries, the flutter_assets directory, the list of .so files found, and the original APK path.

Build.SUPPORTED_ABIS[0] returns the device's preferred architecture (e.g. arm64-v8a). The extractor uses this to pick the right subfolder inside the APK.

The extraction directory lives under context.filesDir which is the app's private internal storage. If the directory already exists from a previous extraction, it gets wiped clean first.

Do not save the file yet. You will add the extraction loop next.

Implement the ZIP extraction loop

The core of the extractor iterates through every entry in the APK's ZIP structure. It filters for two things: native libraries matching the device ABI, and Flutter asset files.

  • Directly below the val soFiles = mutableListOf<File>() line, paste the ZIP extraction logic and closing braces:
        // Open the APK as a ZIP and iterate every entry
        ZipInputStream(apkFile.inputStream()).use { zip ->
            var entry = zip.nextEntry
            while (entry != null) {
                val name = entry.name

                // Extract .so files that match the device architecture
                if (name.startsWith("lib/$targetAbi/") && name.endsWith(".so")) {
                    val soName = name.substringAfterLast("/")
                    val outFile = File(nativeLibDir, soName)
                    FileOutputStream(outFile).use { out ->
                        zip.copyTo(out)
                    }
                    // Android 14+ requires dynamically loaded files to be read-only
                    outFile.setReadOnly()
                    soFiles.add(outFile)
                    Log.d(TAG, "Extracted: $soName")
                }

                // Extract flutter_assets (fonts, shaders, asset manifest)
                if (name.startsWith("assets/flutter_assets/")) {
                    val relativePath = name.removePrefix("assets/flutter_assets/")
                    if (relativePath.isNotEmpty() && !entry.isDirectory) {
                        val outFile = File(flutterAssetsDir, relativePath)
                        outFile.parentFile?.mkdirs()
                        FileOutputStream(outFile).use { out ->
                            zip.copyTo(out)
                        }
                        outFile.setReadOnly()
                    }
                }

                zip.closeEntry()
                entry = zip.nextEntry
            }
        }

        Log.d(TAG, "Extraction complete. ${soFiles.size} .so files, assets at ${flutterAssetsDir.absolutePath}")
        return ExtractionResult(nativeLibDir, flutterAssetsDir, soFiles, apkFile.absolutePath)
    }
}

What does this code do?

  • ZipInputStream treats the APK as a stream of entries. Each entry has a path like lib/arm64-v8a/libflutter.so or assets/flutter_assets/FontManifest.json.
  • The first if block matches entries under the device's ABI folder and writes them to the lib/ output directory.
  • The second if block captures everything under assets/flutter_assets/ and mirrors the subfolder structure in the output.
  • setReadOnly() is called on every extracted file. Starting with Android 14 (API 34), the system requires dynamically loaded files to be read-only or it throws an exception.

Why setReadOnly on all extracted files?

Android 14 introduced a security hardening measure that prevents loading writable .so or .dex files via System.load() or DexClassLoader. Marking files read-only immediately after extraction ensures compatibility across all API levels from 24 to 34+.

  • Save ApkExtractor.kt.
  • Open your terminal and verify the project still compiles by running:
./gradlew assembleDebug

You should see BUILD SUCCESSFUL in the terminal output. The APK is not functional yet (nothing calls the extractor), but a clean build confirms there are no syntax errors.

Seeing a compile error?

  • Check that the package declaration at the top of ApkExtractor.kt is exactly package com.flutterexplorer.app. A mismatch with the directory path causes an unresolved reference.
  • Make sure the file is saved inside app/src/main/kotlin/com/flutterexplorer/app/. Kotlin requires the file path to match the package name.
  • Confirm all opening braces have matching closing braces. The class ends with } for the extract function and another } for the class itself.

Help me debug my ApkExtractor compile error

✔️ Awesome, I've got everything!

Great. Double check you have saved ApkExtractor.kt and your build is passing.

ⓧ I'd like to double check the full code

Here is the complete ApkExtractor.kt file:

package com.flutterexplorer.app

import android.content.Context
import android.os.Build
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream

class ApkExtractor(private val context: Context) {

    companion object {
        private const val TAG = "ApkExtractor"
    }

    data class ExtractionResult(
        val nativeLibDir: File,
        val flutterAssetsDir: File,
        val soFiles: List<File>,
        val apkPath: String
    )

    fun extract(apkFile: File): ExtractionResult {
        val targetAbi = Build.SUPPORTED_ABIS[0]
        Log.d(TAG, "Device ABI: $targetAbi")

        val extractDir = File(context.filesDir, "extracted_${apkFile.nameWithoutExtension}")
        if (extractDir.exists()) extractDir.deleteRecursively()
        extractDir.mkdirs()

        val nativeLibDir = File(extractDir, "lib")
        nativeLibDir.mkdirs()

        val flutterAssetsDir = File(extractDir, "flutter_assets")
        flutterAssetsDir.mkdirs()

        val soFiles = mutableListOf<File>()

        ZipInputStream(apkFile.inputStream()).use { zip ->
            var entry = zip.nextEntry
            while (entry != null) {
                val name = entry.name

                if (name.startsWith("lib/$targetAbi/") && name.endsWith(".so")) {
                    val soName = name.substringAfterLast("/")
                    val outFile = File(nativeLibDir, soName)
                    FileOutputStream(outFile).use { out ->
                        zip.copyTo(out)
                    }
                    outFile.setReadOnly()
                    soFiles.add(outFile)
                    Log.d(TAG, "Extracted: $soName")
                }

                if (name.startsWith("assets/flutter_assets/")) {
                    val relativePath = name.removePrefix("assets/flutter_assets/")
                    if (relativePath.isNotEmpty() && !entry.isDirectory) {
                        val outFile = File(flutterAssetsDir, relativePath)
                        outFile.parentFile?.mkdirs()
                        FileOutputStream(outFile).use { out ->
                            zip.copyTo(out)
                        }
                        outFile.setReadOnly()
                    }
                }

                zip.closeEntry()
                entry = zip.nextEntry
            }
        }

        Log.d(TAG, "Extraction complete. ${soFiles.size} .so files, assets at ${flutterAssetsDir.absolutePath}")
        return ExtractionResult(nativeLibDir, flutterAssetsDir, soFiles, apkFile.absolutePath)
    }
}

Your extraction engine is ready. It can crack open any Flutter APK and lay out its native libraries and assets in a structured directory. Next up, you will build the native library loader that takes those extracted .so files and loads them into process memory in the correct order.

Implement Native Library Pre-Loading

Your ApkExtractor can now pull .so files and flutter_assets out of any Flutter APK. The extracted files sit in your app's private storage, ready for the next stage.

Android loads native libraries into process memory using System.load(). For Flutter, the order you load these libraries matters critically because libapp.so depends on symbols defined inside libflutter.so. In this step, you'll build a NativeLoader class that handles this ordering and reports failures clearly.

In this step, get ready to:
  • Create the NativeLoader class with dependency-ordered library loading.
  • Add error handling that logs which specific .so file failed to load.
Create the NativeLoader class

A Flutter APK contains three categories of native libraries. libflutter.so is the C++ Flutter engine. Plugin .so files provide native platform channel implementations. libapp.so contains the AOT-compiled Dart code and depends on symbols from the engine.

Why does load order matter?

When Android loads a .so file via System.load(), it resolves all symbol dependencies immediately. If libapp.so loads before libflutter.so, the linker cannot find the Flutter engine symbols and throws an UnsatisfiedLinkError.

The correct order is: engine first, plugins second, app last. By calling System.load(absolutePath) for all libraries before the DexClassLoader-loaded code tries System.loadLibrary("flutter"), the symbols are already in process memory.

  • Create a new file at app/src/main/kotlin/com/flutterexplorer/app/NativeLoader.kt and paste the following code:
package com.flutterexplorer.app

import android.util.Log
import java.io.File

class NativeLoader {

    companion object {
        private const val TAG = "NativeLoader"
    }

    fun loadLibraries(soFiles: List<File>) {
        // Sort libraries into correct dependency order before loading
        val sorted = sortByDependencyOrder(soFiles)
        for (soFile in sorted) {
            try {
                // Load each .so by absolute path into process memory
                System.load(soFile.absolutePath)
                Log.d(TAG, "Loaded: ${soFile.name}")
            } catch (e: UnsatisfiedLinkError) {
                // Log which specific library failed so we can diagnose
                Log.e(TAG, "Failed to load ${soFile.name}: ${e.message}")
            }
        }
    }

    private fun sortByDependencyOrder(soFiles: List<File>): List<File> {
        // Engine must load first (provides symbols others depend on)
        val flutterEngine = soFiles.filter { it.name == "libflutter.so" }
        // App must load last (depends on engine symbols)
        val appLib = soFiles.filter { it.name == "libapp.so" }
        // Everything else is a plugin, loads between engine and app
        val plugins = soFiles.filter { it.name != "libflutter.so" && it.name != "libapp.so" }
        return flutterEngine + plugins + appLib
    }
}

What does this code do?

  • sortByDependencyOrder() separates the extracted .so files into three groups: the Flutter engine, plugins, and the Dart app code. It returns them concatenated in that exact order.
  • loadLibraries() iterates the sorted list and calls System.load(absolutePath) for each file. This bypasses the classloader's library search entirely and loads directly from your extracted directory.
  • Each load call is wrapped in a try/catch for UnsatisfiedLinkError. If a library fails, the log names the exact file so you can diagnose whether it's a missing dependency or architecture mismatch.
  • Save NativeLoader.kt.
  • Confirm the project still compiles by running this command in your terminal from the project root:
./gradlew assembleDebug

You should see BUILD SUCCESSFUL in the terminal output. The debug APK appears at app/build/outputs/apk/debug/app-debug.apk.

Seeing a compilation error?

  • Check that the package declaration at the top of NativeLoader.kt is exactly package com.flutterexplorer.app. It must match the file's directory path.
  • Verify the file is saved inside app/src/main/kotlin/com/flutterexplorer/app/ and not in the project root or another directory.
  • Make sure you have the import java.io.File line at the top. Without it, Kotlin cannot resolve the File type.

Help me fix my NativeLoader compilation error

What about the Android 14+ read-only requirement?

Starting from API 34, Android requires that any dynamically loaded file (.so, .dex, .jar) is marked read-only before loading. If it is not, the system throws an exception.

Your ApkExtractor already handles this. It calls outFile.setReadOnly() immediately after writing each extracted .so file to disk. That means by the time NativeLoader calls System.load(), every library file already has the correct permissions.

✔️ Awesome, I've got everything!

Great. Double check you've saved NativeLoader.kt and your build passes.

ⓧ I'd like to double check the full code

Here is the complete NativeLoader.kt file for comparison:

package com.flutterexplorer.app

import android.util.Log
import java.io.File

class NativeLoader {

    companion object {
        private const val TAG = "NativeLoader"
    }

    fun loadLibraries(soFiles: List<File>) {
        val sorted = sortByDependencyOrder(soFiles)
        for (soFile in sorted) {
            try {
                System.load(soFile.absolutePath)
                Log.d(TAG, "Loaded: ${soFile.name}")
            } catch (e: UnsatisfiedLinkError) {
                Log.e(TAG, "Failed to load ${soFile.name}: ${e.message}")
            }
        }
    }

    private fun sortByDependencyOrder(soFiles: List<File>): List<File> {
        val flutterEngine = soFiles.filter { it.name == "libflutter.so" }
        val appLib = soFiles.filter { it.name == "libapp.so" }
        val plugins = soFiles.filter { it.name != "libflutter.so" && it.name != "libapp.so" }
        return flutterEngine + plugins + appLib
    }
}

Your native library loader is ready. It knows the correct dependency order and will report exactly which file fails if something goes wrong at runtime. Next up, you'll use a DexClassLoader to load the Flutter embedding classes from the APK and instantiate a FlutterEngine via reflection.

Load Flutter Classes with DexClassLoader

Your extraction engine pulls native libraries out of an APK, and your NativeLoader pre-loads them into process memory. The missing piece is loading the Java/Kotlin classes from that APK. Without the Flutter embedding classes, you have no FlutterEngine, no DartExecutor, and no way to boot the Dart runtime.

Android provides DexClassLoader specifically for loading classes from APK files that are not installed on the device. In this step, you will create a container Activity that uses DexClassLoader to load Flutter's embedding classes and instantiate a FlutterEngine entirely through reflection.

In this step, get ready to:
  • Create the FlutterContainerActivity with intent extras for APK path, library directory, and assets directory.
  • Instantiate a DexClassLoader pointing at the target APK and use reflection to load Flutter embedding classes.
  • Boot a FlutterEngine and execute the Dart entrypoint with the extracted flutter_assets path.
Create the FlutterContainerActivity class

The container Activity receives three paths via intent extras: the APK file path, the extracted native library directory, and the extracted flutter_assets directory. These three pieces are everything it needs to boot a Flutter app from outside the host APK.

  • Create a new file at app/src/main/kotlin/com/flutterexplorer/app/FlutterContainerActivity.kt.
  • Add the class skeleton with companion object constants, instance fields, and the onCreate method:
package com.flutterexplorer.app

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
import dalvik.system.DexClassLoader

class FlutterContainerActivity : AppCompatActivity() {

    companion object {
        const val EXTRA_APK_PATH = "apk_path"
        const val EXTRA_LIB_DIR = "lib_dir"
        const val EXTRA_ASSETS_DIR = "assets_dir"
        private const val TAG = "FlutterContainer"
    }

    // Store engine and view as Any? because the classes come from an external classloader
    private var flutterEngine: Any? = null
    private var flutterView: View? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Extract the three paths passed from the launching Activity
        val apkPath = intent.getStringExtra(EXTRA_APK_PATH) ?: return finish()
        val libDir = intent.getStringExtra(EXTRA_LIB_DIR) ?: return finish()
        val assetsDir = intent.getStringExtra(EXTRA_ASSETS_DIR) ?: return finish()

        try {
            loadFlutterApp(apkPath, libDir, assetsDir, FrameLayout(this))
        } catch (e: Exception) {
            Log.e(TAG, "Failed to load Flutter app", e)
            finish()
        }
    }
}

What does this code do?

The companion object defines string constants (EXTRA_APK_PATH, EXTRA_LIB_DIR, EXTRA_ASSETS_DIR) used as intent extra keys. Any Activity that launches FlutterContainerActivity passes these three values.

The fields flutterEngine and flutterView are typed as Any? because the actual FlutterEngine and FlutterView classes come from the external APK's classloader. The host app has no compile-time reference to them.

In onCreate, ?: return finish() is a Kotlin idiom that immediately closes the Activity if any required extra is missing.

Why DexClassLoader instead of PathClassLoader?

Android uses PathClassLoader for apps that are already installed on the device. DexClassLoader exists specifically for loading classes from APK or JAR files that are NOT installed. Since your target Flutter APK lives in the Downloads folder (not in the system's app registry), DexClassLoader is the only option.

This file will not compile yet because loadFlutterApp() does not exist. You will add it in the next chunk.

Implement the class loading with DexClassLoader and reflection

The core of this step is a single method that creates a DexClassLoader, loads five Flutter embedding classes by fully-qualified name, and uses reflection to instantiate a FlutterEngine and execute the Dart entrypoint.

  • In FlutterContainerActivity.kt, add the loadFlutterApp() method inside the class body, below the onCreate method:
    private fun loadFlutterApp(apkPath: String, libDir: String, assetsDir: String, frame: FrameLayout) {
        // Create a classloader that can read classes from the target APK
        val classLoader = DexClassLoader(
            apkPath,
            cacheDir.absolutePath,
            libDir,
            classLoader
        )

        // Load the five Flutter embedding classes by fully-qualified name
        val flutterLoaderClass = classLoader.loadClass("io.flutter.embedding.engine.loader.FlutterLoader")
        val engineClass = classLoader.loadClass("io.flutter.embedding.engine.FlutterEngine")
        val dartExecutorClass = classLoader.loadClass("io.flutter.embedding.engine.dart.DartExecutor")
        val dartEntrypointClass = classLoader.loadClass("io.flutter.embedding.engine.dart.DartExecutor\$DartEntrypoint")
        val flutterViewClass = classLoader.loadClass("io.flutter.embedding.android.FlutterView")

        // Instantiate FlutterEngine with the host Activity context
        val engine = engineClass.getConstructor(
            android.content.Context::class.java
        ).newInstance(this)
        flutterEngine = engine

        // Get the DartExecutor from the engine
        val dartExecutor = engineClass.getMethod("getDartExecutor").invoke(engine)

        // Create a DartEntrypoint pointing to the extracted flutter_assets directory
        val entrypoint = dartEntrypointClass.getConstructor(
            String::class.java,
            String::class.java
        ).newInstance(assetsDir, "main")

        // Execute the Dart entrypoint to boot the Dart runtime
        dartExecutorClass.getMethod("executeDartEntrypoint", dartEntrypointClass)
            .invoke(dartExecutor, entrypoint)

        Log.d(TAG, "Flutter engine started")
    }

What does this code do?

The DexClassLoader takes four arguments. apkPath is the APK file containing the DEX bytecode. cacheDir.absolutePath is a deprecated optimized-directory parameter (ignored on API 26+). libDir tells the classloader where to find native libraries. The last argument is the parent classloader.

The five loadClass() calls load Flutter's embedding classes by their fully-qualified Java names. Note the \$ escape for the inner class DartEntrypoint inside DartExecutor.

Since you cannot import these classes at compile time (they exist only in the target APK), everything uses reflection. getConstructor() finds a constructor by parameter types. newInstance() calls it. getMethod() and invoke() call methods on the resulting objects.

The DartEntrypoint constructor receives assetsDir (the path to the extracted flutter_assets folder) and "main" (the Dart entrypoint function name). This tells the engine where to find compiled Dart assets and which function to call to start the app.

Why does librarySearchPath need a filesystem directory?

DexClassLoader's librarySearchPath parameter does NOT accept paths inside a ZIP or APK file. The .so files must already be extracted to the filesystem. This is why your ApkExtractor extracts them to context.filesDir first.

  • Save FlutterContainerActivity.kt.
  • Verify the project compiles by running:
./gradlew assembleDebug

You should see BUILD SUCCESSFUL in the terminal output. The debug APK is generated at app/build/outputs/apk/debug/app-debug.apk.

Seeing 'Unresolved reference: loadFlutterApp'?

Make sure the loadFlutterApp() method is inside the FlutterContainerActivity class body (between the class opening brace and the final closing brace). It should sit below onCreate but still inside the class.

Also check that the import for dalvik.system.DexClassLoader is present at the top of the file.

help me fix a compile error in FlutterContainerActivity

Why is flutterEngine typed as Any?

The FlutterEngine class does not exist in your host app's classpath. It only exists inside the target Flutter APK's DEX bytecode. You cannot import it or cast to it at compile time. Storing it as Any? and using reflection for every method call is the only way to interact with it.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before continuing.

ⓧ I'd like to double check the full code

package com.flutterexplorer.app

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
import dalvik.system.DexClassLoader

class FlutterContainerActivity : AppCompatActivity() {

    companion object {
        const val EXTRA_APK_PATH = "apk_path"
        const val EXTRA_LIB_DIR = "lib_dir"
        const val EXTRA_ASSETS_DIR = "assets_dir"
        private const val TAG = "FlutterContainer"
    }

    private var flutterEngine: Any? = null
    private var flutterView: View? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val apkPath = intent.getStringExtra(EXTRA_APK_PATH) ?: return finish()
        val libDir = intent.getStringExtra(EXTRA_LIB_DIR) ?: return finish()
        val assetsDir = intent.getStringExtra(EXTRA_ASSETS_DIR) ?: return finish()

        try {
            loadFlutterApp(apkPath, libDir, assetsDir, FrameLayout(this))
        } catch (e: Exception) {
            Log.e(TAG, "Failed to load Flutter app", e)
            finish()
        }
    }

    private fun loadFlutterApp(apkPath: String, libDir: String, assetsDir: String, frame: FrameLayout) {
        val classLoader = DexClassLoader(
            apkPath,
            cacheDir.absolutePath,
            libDir,
            classLoader
        )

        val flutterLoaderClass = classLoader.loadClass("io.flutter.embedding.engine.loader.FlutterLoader")
        val engineClass = classLoader.loadClass("io.flutter.embedding.engine.FlutterEngine")
        val dartExecutorClass = classLoader.loadClass("io.flutter.embedding.engine.dart.DartExecutor")
        val dartEntrypointClass = classLoader.loadClass("io.flutter.embedding.engine.dart.DartExecutor\$DartEntrypoint")
        val flutterViewClass = classLoader.loadClass("io.flutter.embedding.android.FlutterView")

        val engine = engineClass.getConstructor(
            android.content.Context::class.java
        ).newInstance(this)
        flutterEngine = engine

        val dartExecutor = engineClass.getMethod("getDartExecutor").invoke(engine)

        val entrypoint = dartEntrypointClass.getConstructor(
            String::class.java,
            String::class.java
        ).newInstance(assetsDir, "main")

        dartExecutorClass.getMethod("executeDartEntrypoint", dartEntrypointClass)
            .invoke(dartExecutor, entrypoint)

        Log.d(TAG, "Flutter engine started")
    }
}

Your app can now load Flutter's embedding classes from any APK and boot a FlutterEngine. Next up, you will build the container UI that actually renders the Flutter view on screen with proper lifecycle forwarding.

Build the Container Activity with FlutterView

Your FlutterContainerActivity can load Flutter classes and start the Dart runtime, but it has no visible UI yet.

In this step, you will build the container that actually displays a loaded Flutter app. This means constructing the layout programmatically, rendering the FlutterView via reflection, and forwarding Android lifecycle events so the hosted app behaves correctly.

In this step, get ready to:
  • Build a programmatic layout with a toolbar and FrameLayout container.
  • Instantiate FlutterView via reflection and attach it to the FlutterEngine.
  • Forward Activity lifecycle events to keep the hosted Flutter app responsive.
Build the programmatic UI layout

Android Activities normally inflate XML layouts, but since your host app is a minimal loader, you will construct the view hierarchy entirely in Kotlin. The layout needs two things: a MaterialToolbar with a back button at the top, and a FrameLayout below it where the FlutterView will render.

  • In FlutterContainerActivity.kt, find the onCreate method.
  • Find this existing try-catch block inside onCreate:
        try {
            loadFlutterApp(apkPath, libDir, assetsDir, FrameLayout(this))
        } catch (e: Exception) {
            Log.e(TAG, "Failed to load Flutter app", e)
            finish()
        }
  • Replace everything from that try-catch down to the end of onCreate with the following layout code and updated try-catch:
        // Build a vertical LinearLayout as the root container
        val container = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }

        // Create a Material toolbar with a back button
        val toolbar = MaterialToolbar(this).apply {
            title = "Flutter Explorer"
            setNavigationIcon(com.google.android.material.R.drawable.ic_arrow_back_black_24)
            setNavigationOnClickListener { finish() }
        }
        container.addView(toolbar, LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.WRAP_CONTENT
        ))

        // Create the frame that will host the FlutterView
        val flutterFrame = FrameLayout(this)
        container.addView(flutterFrame, LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.MATCH_PARENT,
            1f
        ))

        setContentView(container)

        try {
            loadFlutterApp(apkPath, libDir, assetsDir, flutterFrame)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to load Flutter app", e)
            finish()
        }

What does this layout do?

The layout is a vertical LinearLayout with two children stacked top-to-bottom.

  • MaterialToolbar sits at the top with WRAP_CONTENT height. The navigation icon gives users a back button that calls finish() to close the container.
  • FrameLayout fills the remaining space (weight 1f) and serves as the mounting point for the dynamically loaded FlutterView.
  • The loadFlutterApp call now receives flutterFrame instead of a throwaway FrameLayout, so the view it creates will actually appear on screen.
  • Save FlutterContainerActivity.kt.

Seeing an unresolved reference error on MaterialToolbar?

Make sure the import com.google.android.material.appbar.MaterialToolbar is at the top of your file. This class comes from the Material library you added in app/build.gradle.kts.

help me fix unresolved reference errors in FlutterContainerActivity

Instantiate and attach FlutterView via reflection

The FlutterView class lives inside the target APK, not in your host app. You cannot import it directly. Instead, you will use the same DexClassLoader that loaded the FlutterEngine to instantiate a FlutterView, then attach the running engine to it.

  • In FlutterContainerActivity.kt, scroll to the end of the loadFlutterApp method.
  • Find the line that says:
        Log.d(TAG, "Flutter engine started")
  • Replace that line with the following code that creates and attaches the FlutterView:
        // Create a FlutterView instance via reflection
        val view = flutterViewClass.getConstructor(
            android.content.Context::class.java
        ).newInstance(this) as View
        flutterView = view

        // Attach the running engine to the view
        val attachMethod = flutterViewClass.getMethod("attachToFlutterEngine", engineClass)
        attachMethod.invoke(view, engine)

        // Add the FlutterView to the frame container
        frame.addView(view, FrameLayout.LayoutParams(
            FrameLayout.LayoutParams.MATCH_PARENT,
            FrameLayout.LayoutParams.MATCH_PARENT
        ))

        Log.d(TAG, "Flutter app loaded and rendering")

What does this code do?

  • flutterViewClass.getConstructor(...).newInstance(this) creates a FlutterView using its single-argument constructor that takes a Context. You cast it to View so you can add it to the standard Android view hierarchy.
  • attachToFlutterEngine connects the rendering surface to the running Dart runtime. Without this call, the FlutterView would display a blank screen.
  • frame.addView(view, ...) inserts the FlutterView into the FrameLayout with MATCH_PARENT so it fills the available space below the toolbar.
  • Save FlutterContainerActivity.kt.

Why reflection instead of a direct import?

Your host app does not include Flutter as a dependency. The FlutterView and FlutterEngine classes only exist inside the target APK's classes.dex. Reflection lets you interact with classes that your compiler does not know about at build time.

This is the same mechanism Android's plugin frameworks use to load code from external packages without compile-time coupling.

Forward Activity lifecycle events to the FlutterEngine

Flutter apps expect lifecycle signals from the host Android Activity. Without forwarding, animations freeze on pause, timers stall, and the engine leaks memory on destroy.

  • In FlutterContainerActivity.kt, add the following three lifecycle overrides after the closing brace of loadFlutterApp (but before the final closing brace of the class):
    override fun onResume() {
        super.onResume()
        flutterEngine?.let { engine ->
            try {
                val lifecycleChannel = engine.javaClass.getMethod("getLifecycleChannel").invoke(engine)
                lifecycleChannel?.javaClass?.getMethod("appIsResumed")?.invoke(lifecycleChannel)
            } catch (e: Exception) {
                Log.w(TAG, "Failed to notify resumed: ${e.message}")
            }
        }
    }

    override fun onPause() {
        super.onPause()
        flutterEngine?.let { engine ->
            try {
                val lifecycleChannel = engine.javaClass.getMethod("getLifecycleChannel").invoke(engine)
                lifecycleChannel?.javaClass?.getMethod("appIsInactive")?.invoke(lifecycleChannel)
            } catch (e: Exception) {
                Log.w(TAG, "Failed to notify paused: ${e.message}")
            }
        }
    }

    override fun onDestroy() {
        flutterEngine?.let { engine ->
            try {
                engine.javaClass.getMethod("destroy").invoke(engine)
            } catch (e: Exception) {
                Log.w(TAG, "Failed to destroy engine: ${e.message}")
            }
        }
        super.onDestroy()
    }

What do these lifecycle methods do?

  • onResume() tells the Flutter engine the app is visible and interactive. This resumes animations and timers.
  • onPause() signals the app is becoming inactive. Flutter suspends frame rendering to save battery.
  • onDestroy() calls destroy() on the engine to release native memory. Notice it runs before super.onDestroy() so the engine shuts down while the Activity context is still valid.
  • Each method wraps its reflection calls in a try-catch because the Flutter embedding API may vary between versions. Logging warnings instead of crashing keeps the host app resilient.
  • Save FlutterContainerActivity.kt.
  • Confirm the file compiles by running:
./gradlew assembleDebug

You should see BUILD SUCCESSFUL in the terminal output. The APK appears at app/build/outputs/apk/debug/app-debug.apk.

Build failing with unresolved references?

  • Check that all imports are present at the top of your file: android.view.View, android.widget.FrameLayout, android.widget.LinearLayout, com.google.android.material.appbar.MaterialToolbar, and dalvik.system.DexClassLoader.
  • If the Material drawable is unresolved, verify com.google.android.material:material:1.11.0 is in your app/build.gradle.kts dependencies.
  • Make sure the string template in Log.w uses ${e.message} with proper escaping inside the string literal.

help me debug my FlutterContainerActivity build errors

✔️ Awesome, I've got everything!

Great. Double check you have saved FlutterContainerActivity.kt and the build passes cleanly.

ⓧ I'd like to double check the full code

Here is the complete FlutterContainerActivity.kt as it should look at the end of this step:

package com.flutterexplorer.app

import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
import dalvik.system.DexClassLoader

class FlutterContainerActivity : AppCompatActivity() {

    companion object {
        const val EXTRA_APK_PATH = "apk_path"
        const val EXTRA_LIB_DIR = "lib_dir"
        const val EXTRA_ASSETS_DIR = "assets_dir"
        private const val TAG = "FlutterContainer"
    }

    private var flutterEngine: Any? = null
    private var flutterView: View? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val apkPath = intent.getStringExtra(EXTRA_APK_PATH) ?: return finish()
        val libDir = intent.getStringExtra(EXTRA_LIB_DIR) ?: return finish()
        val assetsDir = intent.getStringExtra(EXTRA_ASSETS_DIR) ?: return finish()

        val container = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }

        val toolbar = MaterialToolbar(this).apply {
            title = "Flutter Explorer"
            setNavigationIcon(com.google.android.material.R.drawable.ic_arrow_back_black_24)
            setNavigationOnClickListener { finish() }
        }
        container.addView(toolbar, LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.WRAP_CONTENT
        ))

        val flutterFrame = FrameLayout(this)
        container.addView(flutterFrame, LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.MATCH_PARENT,
            LinearLayout.LayoutParams.MATCH_PARENT,
            1f
        ))

        setContentView(container)

        try {
            loadFlutterApp(apkPath, libDir, assetsDir, flutterFrame)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to load Flutter app", e)
            finish()
        }
    }

    private fun loadFlutterApp(apkPath: String, libDir: String, assetsDir: String, frame: FrameLayout) {
        val classLoader = DexClassLoader(
            apkPath,
            cacheDir.absolutePath,
            libDir,
            classLoader
        )

        val flutterLoaderClass = classLoader.loadClass("io.flutter.embedding.engine.loader.FlutterLoader")
        val engineClass = classLoader.loadClass("io.flutter.embedding.engine.FlutterEngine")
        val dartExecutorClass = classLoader.loadClass("io.flutter.embedding.engine.dart.DartExecutor")
        val dartEntrypointClass = classLoader.loadClass("io.flutter.embedding.engine.dart.DartExecutor\$DartEntrypoint")
        val flutterViewClass = classLoader.loadClass("io.flutter.embedding.android.FlutterView")

        val engine = engineClass.getConstructor(
            android.content.Context::class.java
        ).newInstance(this)
        flutterEngine = engine

        val dartExecutor = engineClass.getMethod("getDartExecutor").invoke(engine)

        val entrypoint = dartEntrypointClass.getConstructor(
            String::class.java,
            String::class.java
        ).newInstance(assetsDir, "main")

        dartExecutorClass.getMethod("executeDartEntrypoint", dartEntrypointClass)
            .invoke(dartExecutor, entrypoint)

        val view = flutterViewClass.getConstructor(
            android.content.Context::class.java
        ).newInstance(this) as View
        flutterView = view

        val attachMethod = flutterViewClass.getMethod("attachToFlutterEngine", engineClass)
        attachMethod.invoke(view, engine)

        frame.addView(view, FrameLayout.LayoutParams(
            FrameLayout.LayoutParams.MATCH_PARENT,
            FrameLayout.LayoutParams.MATCH_PARENT
        ))

        Log.d(TAG, "Flutter app loaded and rendering")
    }

    override fun onResume() {
        super.onResume()
        flutterEngine?.let { engine ->
            try {
                val lifecycleChannel = engine.javaClass.getMethod("getLifecycleChannel").invoke(engine)
                lifecycleChannel?.javaClass?.getMethod("appIsResumed")?.invoke(lifecycleChannel)
            } catch (e: Exception) {
                Log.w(TAG, "Failed to notify resumed: ${e.message}")
            }
        }
    }

    override fun onPause() {
        super.onPause()
        flutterEngine?.let { engine ->
            try {
                val lifecycleChannel = engine.javaClass.getMethod("getLifecycleChannel").invoke(engine)
                lifecycleChannel?.javaClass?.getMethod("appIsInactive")?.invoke(lifecycleChannel)
            } catch (e: Exception) {
                Log.w(TAG, "Failed to notify paused: ${e.message}")
            }
        }
    }

    override fun onDestroy() {
        flutterEngine?.let { engine ->
            try {
                engine.javaClass.getMethod("destroy").invoke(engine)
            } catch (e: Exception) {
                Log.w(TAG, "Failed to destroy engine: ${e.message}")
            }
        }
        super.onDestroy()
    }
}

Your container Activity is fully wired up. It builds a layout, loads the Flutter UI via reflection, and keeps the engine in sync with Android's lifecycle. Next up, you will build a test Flutter APK and run the full system end-to-end to see a Flutter app rendering inside your host.

Wire Up MainActivity and Run End-to-End

You have all the infrastructure classes in place. ApkExtractor pulls native libraries and assets from a Flutter APK. NativeLoader pre-loads them in the correct dependency order. FlutterContainerActivity hosts the dynamically loaded Flutter UI.

Now you need a front door. The MainActivity will present a simple launch button and orchestrate the full pipeline: extract, load, render. You also need something to load, so you will build a minimal Flutter test APK and verify the entire system end-to-end.

In this step, get ready to:
  • Implement MainActivity with a programmatic UI and loading orchestration.
  • Build a minimal Flutter test APK and push it to the device.
  • Build, install, and run the host app to verify Flutter renders inside the container.
Build the MainActivity UI

The current MainActivity.kt is an empty placeholder. You will replace it with a programmatic layout containing a title, subtitle, and a button that kicks off the APK loading process.

  • Open app/src/main/kotlin/com/flutterexplorer/app/MainActivity.kt in VS Code.
  • Replace the entire file contents with the following code:
package com.flutterexplorer.app

import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import java.io.File

class MainActivity : AppCompatActivity() {

    companion object {
        private const val TAG = "MainActivity"
        // Name of the Flutter APK we expect in the Downloads folder
        private const val TEST_APK_NAME = "test_flutter_app.apk"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Build the UI programmatically - no XML layout needed
        val layout = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(48, 48, 48, 48)
        }

        val title = TextView(this).apply {
            text = "Flutter Explorer"
            textSize = 24f
        }
        layout.addView(title)

        val subtitle = TextView(this).apply {
            text = "Load and run Flutter APKs without installation"
            textSize = 14f
        }
        layout.addView(subtitle)

        // The button that triggers the full extract-load-render pipeline
        val loadButton = Button(this).apply {
            text = "Load Test Flutter APK"
            setOnClickListener { loadTestApk() }
        }
        layout.addView(loadButton)

        setContentView(layout)
    }
}

What does this code do?

This builds the entire Activity UI in code rather than XML. The layout is a vertical LinearLayout with 48px padding on all sides.

The TEST_APK_NAME constant defines a known filename the app looks for in the Downloads folder. The button's click listener calls loadTestApk() which you will write next.

Now add the loadTestApk() function that orchestrates extraction, native loading, and launching the container Activity.

  • Add the following function inside the MainActivity class, below the closing brace of onCreate:
    private fun loadTestApk() {
        val downloadsDir = File("/sdcard/Download")
        val testApk = File(downloadsDir, TEST_APK_NAME)

        // Guard: show a Toast if the APK hasn't been placed on the device
        if (!testApk.exists()) {
            Toast.makeText(this, "Place $TEST_APK_NAME in Downloads folder", Toast.LENGTH_LONG).show()
            return
        }

        // Step 1: Extract .so files and flutter_assets from the APK
        Log.d(TAG, "Extracting APK: ${testApk.absolutePath}")
        val extractor = ApkExtractor(this)
        val result = extractor.extract(testApk)

        // Step 2: Pre-load native libraries in dependency order
        Log.d(TAG, "Loading native libraries...")
        val nativeLoader = NativeLoader()
        nativeLoader.loadLibraries(result.soFiles)

        // Step 3: Launch the container Activity with all paths as extras
        val intent = Intent(this, FlutterContainerActivity::class.java).apply {
            putExtra(FlutterContainerActivity.EXTRA_APK_PATH, result.apkPath)
            putExtra(FlutterContainerActivity.EXTRA_LIB_DIR, result.nativeLibDir.absolutePath)
            putExtra(FlutterContainerActivity.EXTRA_ASSETS_DIR, result.flutterAssetsDir.absolutePath)
        }
        startActivity(intent)
    }

What does this code do?

  • It checks /sdcard/Download/test_flutter_app.apk exists on the device. If not, it shows a Toast and bails out.
  • It creates an ApkExtractor and calls extract() to pull native libraries and flutter_assets out of the APK zip.
  • It passes the extracted .so files to NativeLoader so they are loaded into process memory before the Flutter engine tries to use them.
  • It launches FlutterContainerActivity with three Intent extras: the APK path, the extracted native library directory, and the flutter_assets directory.
  • Save MainActivity.kt.

Seeing red squiggles in VS Code?

The Kotlin extension may show import errors before the project is built for the first time. As long as the imports at the top of the file match the code above, the Gradle build will resolve them correctly.

Help me fix Kotlin import errors in VS Code

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

package com.flutterexplorer.app

import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import java.io.File

class MainActivity : AppCompatActivity() {

    companion object {
        private const val TAG = "MainActivity"
        private const val TEST_APK_NAME = "test_flutter_app.apk"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val layout = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(48, 48, 48, 48)
        }

        val title = TextView(this).apply {
            text = "Flutter Explorer"
            textSize = 24f
        }
        layout.addView(title)

        val subtitle = TextView(this).apply {
            text = "Load and run Flutter APKs without installation"
            textSize = 14f
        }
        layout.addView(subtitle)

        val loadButton = Button(this).apply {
            text = "Load Test Flutter APK"
            setOnClickListener { loadTestApk() }
        }
        layout.addView(loadButton)

        setContentView(layout)
    }

    private fun loadTestApk() {
        val downloadsDir = File("/sdcard/Download")
        val testApk = File(downloadsDir, TEST_APK_NAME)

        if (!testApk.exists()) {
            Toast.makeText(this, "Place $TEST_APK_NAME in Downloads folder", Toast.LENGTH_LONG).show()
            return
        }

        Log.d(TAG, "Extracting APK: ${testApk.absolutePath}")
        val extractor = ApkExtractor(this)
        val result = extractor.extract(testApk)

        Log.d(TAG, "Loading native libraries...")
        val nativeLoader = NativeLoader()
        nativeLoader.loadLibraries(result.soFiles)

        val intent = Intent(this, FlutterContainerActivity::class.java).apply {
            putExtra(FlutterContainerActivity.EXTRA_APK_PATH, result.apkPath)
            putExtra(FlutterContainerActivity.EXTRA_LIB_DIR, result.nativeLibDir.absolutePath)
            putExtra(FlutterContainerActivity.EXTRA_ASSETS_DIR, result.flutterAssetsDir.absolutePath)
        }
        startActivity(intent)
    }
}
Build a test Flutter APK

The host app needs a real Flutter APK to load. You will create the simplest possible Flutter app, build it in release mode (so the Dart code is AOT-compiled into libapp.so), and place the resulting APK where the host app expects it.

  • Open a new terminal window separate from your FlutterExplorer project.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a minimal Flutter app by running:
flutter create test_flutter_app
  • Move into the new project directory by running:
cd test_flutter_app
  • Build the release APK by running:
flutter build apk --release

Why release mode?

A release build compiles Dart code ahead-of-time into libapp.so. Debug builds use a JIT compiler and require a running Dart VM, which makes dynamic loading far more complex. Release mode gives you a self-contained set of native libraries that your loader can handle.

  • When the build finishes, you should see a message telling you the APK was built at build/app/outputs/flutter-apk/app-release.apk.

Build failed?

Make sure you have the Flutter SDK installed and on your PATH. Run flutter doctor to check for missing dependencies. The build requires a working Android SDK (which you already have from Android Studio).

Help me fix my Flutter build

Push the APK to the device and run end-to-end

Your host app looks for test_flutter_app.apk in the device's Downloads folder. You will push the built APK there using ADB, then build and install the host app to test the full pipeline.

  • Push the Flutter APK to the device's Downloads folder by running:
adb push build/app/outputs/flutter-apk/app-release.apk /sdcard/Download/test_flutter_app.apk

What does this command do?

The adb push command copies a file from your computer to the connected Android device. You are renaming the APK to test_flutter_app.apk as you push it, which matches the TEST_APK_NAME constant in your MainActivity.

  • You should see a progress bar followed by a transfer confirmation in the terminal output.
  • Now switch back to your FlutterExplorer project directory by running:
cd ~/Desktop/FlutterExplorer
  • Build and install the host app on your device by running:
./gradlew installDebug

What does this command do?

This compiles the FlutterExplorer host app, produces a debug APK at app/build/outputs/apk/debug/app-debug.apk, and installs it directly onto your connected device or emulator.

  • Wait for the build to complete. You should see BUILD SUCCESSFUL in the terminal output.
  • Open the Flutter Explorer app on your device. You should see a screen with the title "Flutter Explorer", a subtitle, and a "Load Test Flutter APK" button.
  • Tap Load Test Flutter APK.

The app extracts the Flutter APK, loads native libraries, and launches the container. You should see the default Flutter demo app (a counter with a floating action button) rendered inside a screen with a "Flutter Explorer" toolbar and a back arrow at the top.

  • Tap the back arrow in the toolbar to return to the main screen.

App crashes when you tap the button?

Check the logcat output for the specific error. Common issues:

  • "Permission denied" on the APK file: On Android 10+, scoped storage may block direct access to /sdcard/Download. Grant the app storage permission manually in Settings > Apps > Flutter Explorer > Permissions.
  • "UnsatisfiedLinkError" for a specific .so: The APK may have been built for a different ABI than your device. Run adb shell getprop ro.product.cpu.abi to check your device architecture and confirm the Flutter APK contains matching libraries.
  • "ClassNotFoundException" for Flutter classes: Make sure the test APK was built in release mode. Debug builds use a different class structure.

Help me debug my Flutter Explorer crash

View logcat from VS Code

If you have the DroidKit extension, open the command palette with Ctrl+Shift+P and search for "DroidKit: Logcat". Filter by FlutterContainer or NativeLoader to see your app's debug logs.

Your Flutter Explorer app is fully functional. You have proven that a pure Kotlin host can extract, load, and render a Flutter app at runtime without ever installing the target APK. Next up, the Secret Mission challenges you to add a proper file picker so users can load any Flutter APK from their device.

Secret mission

File Picker and App Grid

Your Flutter Explorer currently loads a test APK from a hardcoded path. In this secret mission, you'll add a file picker that lets users browse and select any Flutter APK from device storage. You'll also build a grid of previously loaded apps using RecyclerView and SharedPreferences.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine and Android device, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • FlutterExplorer Kotlin project folder on your Desktop.
  • Test Flutter app project folder (test_flutter_app).
  • Installed FlutterExplorer debug APK on your Android device or emulator.
  • Test Flutter APK pushed to your device's Downloads folder.
  • VS Code extensions: DroidKit and Kotlin language extension.
  • Extracted native libraries and assets in the app's private storage on device.

Keep everything running

No action needed. Choose this if you're still actively experimenting with loading different Flutter APKs or want to extend the project further.

  • Your FlutterExplorer project remains on your Desktop, ready to build and run.
  • The test APK stays in your device's Downloads folder for quick testing.
  • VS Code extensions remain installed for future Android development.

Pause - I'll come back to this later

Free up device storage while keeping your project files intact on your computer.

  • Uninstall the debug APK from your device by running:
adb uninstall com.flutterexplorer.app
  • Remove the test APK from your device's Downloads folder by running:
adb shell rm /sdcard/Download/test_flutter_app.apk
  • Keep your project folders on your Desktop. You can rebuild and redeploy anytime with ./gradlew installDebug.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Uninstall the debug APK from your device by running:
adb uninstall com.flutterexplorer.app
  • Remove the test APK from your device by running:
adb shell rm /sdcard/Download/test_flutter_app.apk
  • Delete the FlutterExplorer project folder from your Desktop by running:
rm -rf ~/Desktop/FlutterExplorer
  • Delete the test Flutter app project folder by running:
rm -rf ~/Desktop/test_flutter_app
  • Optionally, uninstall the VS Code extensions if you no longer need them by running:
code --uninstall-extension dayanleksonoputro.droidkit
code --uninstall-extension fwcd.kotlin

That's a Wrap!

That's a Wrap!

Nice work! You've just built Flutter Explorer, a runtime APK loader that proves you understand how Flutter apps work at the deepest native level.

You've learned how to:

  • Scaffold a pure Kotlin Android project from the command line and dissect Flutter's internal APK structure (the 3-layer architecture of libapp.so, libflutter.so, and classes.dex).
  • Implement dynamic code loading with DexClassLoader and native library pre-loading with System.load() to run external APK code at runtime.
  • Build a container Activity that hosts a dynamically loaded FlutterView with proper lifecycle forwarding for resume, pause, and destroy events.
  • Secret Mission: Added a file picker using Android's Storage Access Framework and a RecyclerView grid for browsing and loading multiple Flutter apps.

Ready to quiz yourself?