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 coderesources.arsc- Compiled resourcesres/- Images, layouts, etc.lib/- Native libraries for all architecturesAndroidManifest.xml- App metadataMETA-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.