SuriDevs Logo
Understanding Android's Package System: APKs, Split APKs, and App Bundles

Understanding Android's Package System: APKs, Split APKs, and App Bundles

By Sagar Maiyad  Oct 28, 2025

I was building an APK backup tool when I hit a wall. Extracted Chrome's APK, tried to install it on another device. Crashed immediately.

Turns out Chrome isn't one APK anymore. It's like twelve of them. Welcome to the world of split APKs.

When APKs Were Simple

The classic APK is just a ZIP file:

  • classes.dex - Your compiled code
  • resources.arsc - Compiled resources
  • res/ - Images, layouts, etc.
  • lib/ - Native libraries for all architectures
  • AndroidManifest.xml - App metadata
  • META-INF/ - Signatures

One file, everything inside. Install it, done.

The problem? That one file has resources for EVERY device configuration. Your ARM phone downloads x86 libraries it can't use. Your 1080p screen downloads 4K tablet assets. Waste of space, waste of bandwidth.

Split APKs Changed Everything

Now apps can be split into pieces:

Base APK - Core code, always required
Config APKs - Device-specific stuff:

  • Density splits (hdpi, xhdpi, xxhdpi)
  • ABI splits (armeabi-v7a, arm64-v8a, x86)
  • Language splits (en, es, ja)

Play Store only delivers the splits you need. That 100MB app? Maybe 40MB on your specific device.

Great for users. Annoying for developers who need to work with these files programmatically.

App Bundles: The Developer Side

We upload App Bundles (.aab) to Play Store. Google generates optimized APKs for each device. You never see the AAB on devices - it's a publishing format, not an installation format.

So when you're trying to backup or inspect an app, you're dealing with whatever combination of split APKs that device received. Not the original bundle.

Getting App Info with PackageManager

Android's PackageManager is how you query installed apps:

class AppListManager(private val context: Context) {

    private val packageManager = context.packageManager

    fun getInstalledApps(includeSystem: Boolean = false): List<AppInfo> {
        val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            PackageManager.MATCH_UNINSTALLED_PACKAGES
        } else {
            PackageManager.GET_UNINSTALLED_PACKAGES
        }

        return packageManager.getInstalledApplications(flags)
            .filter { app ->
                if (includeSystem) true
                else (app.flags and ApplicationInfo.FLAG_SYSTEM) == 0
            }
            .map { appInfo ->
                AppInfo(
                    packageName = appInfo.packageName,
                    appName = packageManager.getApplicationLabel(appInfo).toString(),
                    icon = packageManager.getApplicationIcon(appInfo),
                    sourceDir = appInfo.sourceDir,
                    splitSourceDirs = appInfo.splitSourceDirs,
                    isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0,
                    installedTime = getInstalledTime(appInfo.packageName),
                    lastUpdated = getLastUpdateTime(appInfo.packageName)
                )
            }
            .sortedBy { it.appName.lowercase() }
    }

    private fun getInstalledTime(packageName: String): Long {
        return try {
            packageManager.getPackageInfo(packageName, 0).firstInstallTime
        } catch (e: PackageManager.NameNotFoundException) {
            0L
        }
    }

    private fun getLastUpdateTime(packageName: String): Long {
        return try {
            packageManager.getPackageInfo(packageName, 0).lastUpdateTime
        } catch (e: PackageManager.NameNotFoundException) {
            0L
        }
    }
}

data class AppInfo(
    val packageName: String,
    val appName: String,
    val icon: Drawable,
    val sourceDir: String,
    val splitSourceDirs: Array<String>?,
    val isSystemApp: Boolean,
    val installedTime: Long,
    val lastUpdated: Long
) {
    val isSplitApk: Boolean
        get() = splitSourceDirs?.isNotEmpty() == true
}

The key thing here: splitSourceDirs. If it's not empty, you're dealing with a split APK app. Which changes everything.

Finding All the APK Files

When an app uses splits, you need ALL of them:

fun getApkFiles(packageName: String): List<File> {
    val appInfo = packageManager.getApplicationInfo(packageName, 0)
    val files = mutableListOf<File>()

    // Base APK - always exists
    files.add(File(appInfo.sourceDir))

    // Split APKs - might be many, might be none
    appInfo.splitSourceDirs?.forEach { splitPath ->
        files.add(File(splitPath))
    }

    return files
}

fun getApkDetails(packageName: String): ApkDetails {
    val files = getApkFiles(packageName)
    val baseApk = files.first()
    val splits = files.drop(1)

    return ApkDetails(
        packageName = packageName,
        baseApkPath = baseApk.absolutePath,
        baseApkSize = baseApk.length(),
        splitApks = splits.map { SplitApkInfo(it.name, it.absolutePath, it.length()) },
        totalSize = files.sumOf { it.length() },
        isSplit = splits.isNotEmpty()
    )
}

data class ApkDetails(
    val packageName: String,
    val baseApkPath: String,
    val baseApkSize: Long,
    val splitApks: List<SplitApkInfo>,
    val totalSize: Long,
    val isSplit: Boolean
)

data class SplitApkInfo(
    val name: String,
    val path: String,
    val size: Long
)

Chrome on my phone has 11 split APKs. Gmail has 8. These aren't small apps.

Extracting APKs (The Right Way)

This is where I messed up initially. Just copying the base APK doesn't work for split apps. You need everything bundled together:

class ApkExtractor(private val context: Context) {

    suspend fun extractApk(
        packageName: String,
        outputDir: File
    ): ExtractionResult = withContext(Dispatchers.IO) {
        val packageManager = context.packageManager
        val appInfo = packageManager.getApplicationInfo(packageName, 0)
        val appName = packageManager.getApplicationLabel(appInfo).toString()
            .replace("[^a-zA-Z0-9.-]".toRegex(), "_")

        val packageInfo = packageManager.getPackageInfo(packageName, 0)
        val versionName = packageInfo.versionName ?: "unknown"

        val baseApk = File(appInfo.sourceDir)
        val splits = appInfo.splitSourceDirs?.map { File(it) } ?: emptyList()

        if (splits.isEmpty()) {
            // Simple case - just copy the APK
            val outputFile = File(outputDir, "${appName}_v${versionName}.apk")
            baseApk.copyTo(outputFile, overwrite = true)
            ExtractionResult.SingleApk(outputFile)
        } else {
            // Bundle all the splits together
            val outputFile = File(outputDir, "${appName}_v${versionName}.apks")
            bundleSplitApks(baseApk, splits, outputFile, appInfo)
            ExtractionResult.BundledApks(outputFile, 1 + splits.size)
        }
    }

    private fun bundleSplitApks(
        baseApk: File,
        splits: List<File>,
        outputFile: File,
        appInfo: ApplicationInfo
    ) {
        ZipOutputStream(FileOutputStream(outputFile)).use { zipOut ->
            addFileToZip(zipOut, baseApk, "base.apk")

            splits.forEachIndexed { index, split ->
                addFileToZip(zipOut, split, "split_$index.apk")
            }

            // Metadata helps when restoring
            val metadata = createMetadata(appInfo)
            zipOut.putNextEntry(ZipEntry("metadata.json"))
            zipOut.write(metadata.toByteArray())
            zipOut.closeEntry()

            extractAndAddIcon(zipOut, appInfo)
        }
    }

    private fun addFileToZip(zipOut: ZipOutputStream, file: File, entryName: String) {
        zipOut.putNextEntry(ZipEntry(entryName))
        file.inputStream().use { input ->
            input.copyTo(zipOut)
        }
        zipOut.closeEntry()
    }

    private fun createMetadata(appInfo: ApplicationInfo): String {
        val packageInfo = context.packageManager.getPackageInfo(appInfo.packageName, 0)
        return Json.encodeToString(
            mapOf(
                "packageName" to appInfo.packageName,
                "versionName" to (packageInfo.versionName ?: ""),
                "versionCode" to PackageInfoCompat.getLongVersionCode(packageInfo),
                "minSdk" to appInfo.minSdkVersion,
                "targetSdk" to appInfo.targetSdkVersion
            )
        )
    }

    private fun extractAndAddIcon(zipOut: ZipOutputStream, appInfo: ApplicationInfo) {
        val icon = context.packageManager.getApplicationIcon(appInfo)
        if (icon is BitmapDrawable) {
            zipOut.putNextEntry(ZipEntry("icon.png"))
            icon.bitmap.compress(Bitmap.CompressFormat.PNG, 100, zipOut)
            zipOut.closeEntry()
        }
    }
}

sealed class ExtractionResult {
    data class SingleApk(val file: File) : ExtractionResult()
    data class BundledApks(val file: File, val apkCount: Int) : ExtractionResult()
}

I use .apks extension for bundled splits. Some tools use .xapk or .apkm. Pick whatever, just be consistent.

Installing Split APKs

Simple APKs use the old ACTION_VIEW intent. Split APKs need PackageInstaller:

class ApkInstaller(private val context: Context) {

    fun installApk(apkFile: File) {
        val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            FileProvider.getUriForFile(
                context,
                "${context.packageName}.fileprovider",
                apkFile
            )
        } else {
            Uri.fromFile(apkFile)
        }

        val intent = Intent(Intent.ACTION_VIEW).apply {
            setDataAndType(uri, "application/vnd.android.package-archive")
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
        }

        context.startActivity(intent)
    }

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    suspend fun installSplitApks(apksFile: File): Boolean = withContext(Dispatchers.IO) {
        val packageInstaller = context.packageManager.packageInstaller
        val sessionParams = PackageInstaller.SessionParams(
            PackageInstaller.SessionParams.MODE_FULL_INSTALL
        )

        val sessionId = packageInstaller.createSession(sessionParams)
        val session = packageInstaller.openSession(sessionId)

        try {
            ZipFile(apksFile).use { zip ->
                zip.entries().asSequence()
                    .filter { it.name.endsWith(".apk") }
                    .forEach { entry ->
                        session.openWrite(entry.name, 0, entry.size).use { output ->
                            zip.getInputStream(entry).use { input ->
                                input.copyTo(output)
                            }
                            session.fsync(output)
                        }
                    }
            }

            val intent = Intent(context, InstallResultReceiver::class.java)
            val pendingIntent = PendingIntent.getBroadcast(
                context,
                sessionId,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
            )

            session.commit(pendingIntent.intentSender)
            true
        } catch (e: Exception) {
            session.abandon()
            false
        }
    }
}

That session.abandon() in the catch block? Learned that the hard way. Leaked sessions pile up and eventually you can't create new ones. Fun debugging session that was.

Signature Verification

APKs must be signed. Android checks this to make sure updates come from the same developer. If you're building a backup tool, you might want to show this info:

fun getSignatureInfo(packageName: String): SignatureInfo {
    val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        context.packageManager.getPackageInfo(
            packageName,
            PackageManager.GET_SIGNING_CERTIFICATES
        )
    } else {
        @Suppress("DEPRECATION")
        context.packageManager.getPackageInfo(
            packageName,
            PackageManager.GET_SIGNATURES
        )
    }

    val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        packageInfo.signingInfo?.apkContentsSigners ?: emptyArray()
    } else {
        @Suppress("DEPRECATION")
        packageInfo.signatures ?: emptyArray()
    }

    return SignatureInfo(
        signatureCount = signatures.size,
        sha256 = signatures.firstOrNull()?.let { calculateSha256(it) },
        sha1 = signatures.firstOrNull()?.let { calculateSha1(it) }
    )
}

private fun calculateSha256(signature: Signature): String {
    val digest = MessageDigest.getInstance("SHA-256")
    val hash = digest.digest(signature.toByteArray())
    return hash.joinToString(":") { "%02X".format(it) }
}

The signature APIs changed in Android P. You need to handle both. That @Suppress("DEPRECATION") is ugly but necessary.

Storage Gets Complicated on Android 10+

Used to be you could just write to external storage. Not anymore:

class StorageHelper(private val context: Context) {

    // Always works - app-specific directory
    fun getAppSpecificDir(): File {
        return context.getExternalFilesDir(null)
            ?: context.filesDir
    }

    // For user-visible files on Android 10+
    @RequiresApi(Build.VERSION_CODES.Q)
    suspend fun saveToDownloads(
        sourceFile: File,
        displayName: String
    ): Uri? = withContext(Dispatchers.IO) {
        val values = ContentValues().apply {
            put(MediaStore.Downloads.DISPLAY_NAME, displayName)
            put(MediaStore.Downloads.MIME_TYPE, "application/vnd.android.package-archive")
            put(MediaStore.Downloads.IS_PENDING, 1)
        }

        val uri = context.contentResolver.insert(
            MediaStore.Downloads.EXTERNAL_CONTENT_URI,
            values
        )

        uri?.let {
            context.contentResolver.openOutputStream(it)?.use { output ->
                sourceFile.inputStream().use { input ->
                    input.copyTo(output)
                }
            }

            values.clear()
            values.put(MediaStore.Downloads.IS_PENDING, 0)
            context.contentResolver.update(uri, values, null, null)
        }

        uri
    }
}

The IS_PENDING flag is weird but required. You set it to 1 while writing, then 0 when done. Otherwise other apps might try to read your half-written file.

What Tripped Me Up

Forgot split APKs exist - tried installing just base.apk for Chrome. Crashed on launch. Now I always check splitSourceDirs.

Didn't call session.abandon() on errors. Ran out of available sessions after a few failed installs. Had to restart the phone.

FileProvider wasn't configured. Crashed on Android 7+ when trying to share APK files. The error message doesn't tell you it's a FileProvider issue. Took a while to figure out.

Tried writing to external storage on Android 11. Permission denied even with WRITE_EXTERNAL_STORAGE. Scoped storage is mandatory now.

Testing This Stuff

Test with real Play Store apps. They're the ones with split APKs. Sideloaded APKs are usually single files and won't exercise your split handling code.

Good test apps: Chrome (lots of splits), Gmail, Google Maps. They're complex and will break your assumptions.

Android APK App Bundle PackageManager

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Latest Post

Latest Tags