3

Background

I want to be able to parse APK files from various sources, or various kinds. I want to get only very specific, basic information about them:

  • package-name
  • version-code
  • version-name
  • label (app name)
  • icon
  • minSdkVersion
  • whether it's a split APK or not (available when parsing the manifest)

The Android framework has a single function to parse APK files (PackageManager.getPackageArchiveInfo), but it has 2 disadvantages:

  1. It requires a file-path. This isn't always available, as you might have a Uri to deal with, or you have the APK file being inside some ZIP file.

  2. It can't handle split APK files. Only the base one of them. If you try it on split APK files, you just get null.

Because of this, I've tried to find a reliable, Java&Kotlin solution that can handle them easily.

The problem

Each solution I've found has its own disadvatanges. Some libraries I couldn't even find how to use, and some don't have any way to parse APK files without having a real file path. Some of them just create new files instead of returning you objects in Java/Kotlin. Some are even in other programming languages, making me wonder if it's possible to use them.

So far, the only one that I've found as good enough, is "hsiafan" apk-parser, which needs only 2 files within the APK: manifest and resources files. It has some issues, but usually it can get you the information I've mentioned.

Thing is, as I wrote, it's not always working, so I want to return to the basics, at least when I notice it fails to parse, at least for the case that it's a normal APK of base of split-APK files. Some cases it couldn't handle well are:

  1. Sometimes the app name couldn't be fetched correctly (here).
  2. Some resources are somehow hidden in some apps (here). Even tools such as Jadx fail to show them, but others can.
  3. Adaptive icon is hard to parse, including drawable XML files. I succeed parsing VectorDrawable though (here), but it's quite a workaround.

And a few others.

What I've tried

So, I wanted to try out the Android framework again, but this time with a new idea: instead of handling the entire original APK, I could extract only what it really needs, based on the current observation of it.

For example, if I know the resources I need to handle, I can copy only them (and some key files that are needed) into a new APK, and ditch the rest. This could reduce the need to copy/download huge amount of data just to parse the APK and get information about it. If the APK file is 100MB, for example, there is no need to get it all when all we need is just a tiny portion of it.

As a start, I wanted to see how I can create a new APK that can be parsed, so for now I copied all of the entries of the original APK (currently of the current app) into a new file, and chose to parse it:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (savedInstanceState != null)
            return
        thread {
            val originalApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
            val filePath = originalApplicationInfo.publicSourceDir
            val outputFile = File(cacheDir, "test.apk")
            outputFile.parentFile!!.mkdirs()
            outputFile.delete()
            ZipFile(filePath).use { zipFile ->
                ZipOutputStream(FileOutputStream(outputFile)).use { zipOutputStream ->
                    for (entry in zipFile.entries()) {
                        val name = entry.name
                        zipOutputStream.putNextEntry(ZipEntry(name))
                        zipFile.getInputStream(entry).use { it.copyTo(zipOutputStream.buffered()) }
                        zipOutputStream.closeEntry()
                    }
                }
            }
            val originalLabel = originalApplicationInfo.loadLabel(packageManager)
            val originalIcon: Drawable? = originalApplicationInfo.loadIcon(packageManager)
            Log.d("AppLog", "originalPackageInfo: label:$originalLabel appIcon:${originalIcon?.javaClass?.simpleName}")
            //
            val packageArchiveInfo = packageManager.getPackageArchiveInfo(outputFile.absolutePath, 0)
            val label = packageArchiveInfo?.applicationInfo?.loadLabel(packageManager)?.toString()
            val appIcon = packageArchiveInfo?.applicationInfo?.loadIcon(packageManager)
            Log.d("AppLog", "packageArchiveInfo!=null?${packageArchiveInfo != null} label:$label appIcon:${appIcon?.javaClass?.simpleName}")
        }
    }
}

The file indeed gets generated, but for some reason the Android framework failed to parse it, as packageArchiveInfo is null.

The questions

  1. How come the sample I've made doesn't work? How come the new APK can't be parsed?
  2. What is the minimal set of files the getPackageArchiveInfo function requires to be able to parse the APK, just for the information I've mentioned above?
  3. If this kind of solution doesn't work, is there perhaps a library that could handle APK files of all kinds with all the information I've mentioned, no matter the source (including Uri and within zip files)?

Edit: as suggested, I could copy just those of folder "AndroidManifest.xml", "resources.arsc", "res" , but it seems it can still not always work well, as the icons don't always get to be the same:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (savedInstanceState != null)
            return
        thread {
            val installedApplications = packageManager.getInstalledPackages(0)
            Log.d("AppLog", "checking ${installedApplications.size} apps")
            for (originalPackageInfo in installedApplications) {
                val originalApplicationInfo = originalPackageInfo.applicationInfo
                val filePath = originalApplicationInfo.publicSourceDir
                val outputFile = File(cacheDir, "test.apk")
                outputFile.parentFile!!.mkdirs()
                outputFile.delete()
                val toExtract = setOf<String>("AndroidManifest.xml", "resources.arsc", "res")
                ZipFile(filePath).use { zipFile ->
                    ZipOutputStream(FileOutputStream(outputFile)).use { zipOutputStream ->
                        for (entry in zipFile.entries()) {
                            val name = entry.name
                            if (toExtract.contains(name.split("/")[0])) {
                                zipOutputStream.putNextEntry(ZipEntry(name))
                                zipFile.getInputStream(entry).use { inStream ->
                                    zipOutputStream.buffered().apply {
                                        inStream.copyTo(this)
                                    }.flush()
                                }
                            }
                        }
                    }
                }
                val packageName = originalApplicationInfo.packageName
                val originalLabel = originalApplicationInfo.loadLabel(packageManager)
                val originalIcon: Drawable? = originalApplicationInfo.loadIcon(packageManager)
                val originalIconBitmap = originalIcon?.toBitmap()
                //
                val packageArchiveInfo = packageManager.getPackageArchiveInfo(outputFile.absolutePath, 0)
                if (packageArchiveInfo == null) {
                    Log.e("AppLog", "$packageName could not parse generated APK")
                    continue
                }
                val label = packageArchiveInfo.applicationInfo.loadLabel(packageManager).toString()
                val appIcon = packageArchiveInfo?.applicationInfo?.loadIcon(packageManager)
                val appIconBitmap = appIcon?.toBitmap()
                when {
                    label != originalLabel ->
                        Log.e("AppLog", "$packageName got wrong label $label vs $originalLabel")
                    packageArchiveInfo.versionName != originalPackageInfo.versionName ->
                        Log.e("AppLog", "$packageName got wrong versionName ${packageArchiveInfo.versionName} vs ${originalPackageInfo.versionName}")
                    packageArchiveInfo.versionCode != originalPackageInfo.versionCode ->
                        Log.e("AppLog", "$packageName got wrong versionCode ${packageArchiveInfo.versionCode} vs ${originalPackageInfo.versionCode}")
                    Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && packageArchiveInfo.applicationInfo.minSdkVersion != originalApplicationInfo.minSdkVersion ->
                        Log.e("AppLog", "$packageName got wrong minSdkVersion ${packageArchiveInfo.applicationInfo.minSdkVersion} vs ${originalApplicationInfo.minSdkVersion}")
                    appIcon?.javaClass?.name != originalIcon?.javaClass?.name ->
                        Log.e("AppLog", "$packageName got different app icon type: ${appIcon?.javaClass?.simpleName} vs ${originalIcon?.javaClass?.simpleName}")
                    originalIconBitmap != null && appIconBitmap != null && (originalIconBitmap.width != appIconBitmap.width || originalIconBitmap.height != appIconBitmap.height) ->
                        Log.e("AppLog", "$packageName got wrong app icons sizes:${appIconBitmap.width}x${appIconBitmap.height} vs ${originalIconBitmap.width}x${originalIconBitmap.height}")
                    originalIconBitmap != null && appIconBitmap != null && !areBitmapsSame(originalIconBitmap, appIconBitmap) ->
                        Log.e("AppLog", "$packageName got wrong app icons content ")
                    (originalIconBitmap == null && appIconBitmap != null) || (originalIconBitmap != null && appIconBitmap == null) ->
                        Log.e("AppLog", "$packageName null vs non-null app icon: ${appIconBitmap != null} vs ${originalIconBitmap != null}")
                }
            }
            Log.d("AppLog", "done")
        }
    }

    fun areBitmapsSame(bitmap: Bitmap, bitmap2: Bitmap): Boolean {
        if (bitmap.width != bitmap2.width || bitmap.height != bitmap2.height)
            return false
        for (x in 0 until bitmap.width)
            for (y in 0 until bitmap.height)
                if (bitmap.getPixel(x, y) != bitmap2.getPixel(x, y))
                    return false
        return true
    }
}

I think that since app icons are very complex and depend on various resources (which might even be hidden in weird ways), there is no other choice than to actually have the file on the file system.


EDIT: about getting the app icons, I actually didn't use it well for the APK file. I used something a bit different on my own app, which seems to work well here too, except for some cases that the icon is a bit different (shape/color might be different for example), probably due to different configurations. But at least it won't return you the default app icon of Android when the app clearly has a non-default app icon.

Sadly though, it didn't always get good app icon when creating the minimized APKs (sometimes returned me ColorStateListDrawable or ColorDrawable, for example), probably because sometimes the original APK has hidden resources in non-conventional paths. So this is how you can get the app icon, assuming you have the whole APK:

Before getting it, use:

packageArchiveInfo.applicationInfo.publicSourceDir = targetFilePath
packageArchiveInfo.applicationInfo.sourceDir = targetFilePath

And then call this function:

    fun getAppIcon(context: Context, applicationInfo: ApplicationInfo): Drawable? {
        val packageManager = context.packageManager
        try {
            val iconResId = applicationInfo.icon
            if (iconResId != 0) {
                val resources: Resources = packageManager.getResourcesForApplication(applicationInfo)
                val density = context.resources.displayMetrics.densityDpi
                var result = ResourcesCompat.getDrawableForDensity(resources, iconResId, density, null)
                if (result != null)
                    return result
            }
        } catch (e: Exception) {
//            e.printStackTrace()
        }
        try {
            val applicationIcon = packageManager.getApplicationIcon(applicationInfo)
//            Log.d("AppLog", "getApplicationIcon type:${applicationIcon.javaClass.simpleName}")
            return applicationIcon
        } catch (ignored: Exception) {
        }
        return null
    }

To convert to Bitmap, you can use:

val appIconBitmap = try {
    appIcon?.toBitmap(appIconSize, appIconSize)
} catch (e: Exception) {
    e.printStackTrace()
    null
}

And to get the app icon size, you can use:

    fun getAppIconSize(context: Context): Int {
        val activityManager = context.getSystemService<ActivityManager>()
        val appIconSize = try {
            activityManager.launcherLargeIconSize
        } catch (e: Exception) {
            TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48f, context.resources.displayMetrics).toInt()
        }
        return appIconSize
    }
android developer
  • 114,585
  • 152
  • 739
  • 1,270

1 Answers1

2

Change the copy code from

zipFile.getInputStream(entry).use { it.copyTo(zipOutputStream.buffered()) }

to

zipFile.getInputStream(entry).use {
    val bufStream = zipOutputStream.buffered()
    it.copyTo(bufStream)
    bufStream.flush()
}

or something similar to make sure that all the data gets written out.

I thought that the APK was failing certificate verification, but that is not the case. The foregoing will get you the label and the icon.

With the original code I was seeing the following error

chunk size is bigger than given data
Failed to load 'resources.arsc' in APK '/data/user/0/com

which indicates to me that something went awry with the output. With the aforementioned change, I see the following:

originalPackageInfo: label:APK Copy and Read appIcon:AdaptiveIconDrawable
packageArchiveInfo!=null?true label:APK Copy and Read appIcon:AdaptiveIconDrawable

If I open the created APK from the Device Explorer in Android Studio, it parses out nicely.

As for the minimum, I think, for your purposes, you will need the manifest, the resource.arsc file and the res directory. Here is how to get a reduced APK that will parse with just these elements:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        if (savedInstanceState != null)
            return
        thread {
            val originalApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
            val filePath = originalApplicationInfo.publicSourceDir
            val outputFile = File(cacheDir, "test.apk")
            outputFile.parentFile!!.mkdirs()
            outputFile.delete()
            val toExtract = setOf<String>("AndroidManifest.xml", "resources.arsc","res")
            ZipFile(filePath).use { zipFile ->
                ZipOutputStream(FileOutputStream(outputFile)).use { zipOutputStream ->
                    for (entry in zipFile.entries()) {
                        val name = entry.name
                        if (toExtract.contains(name.split("/")[0])) {
                            zipOutputStream.putNextEntry(ZipEntry(name))
                            zipFile.getInputStream(entry).use { inStream ->
                                zipOutputStream.buffered().apply {
                                    inStream.copyTo(this)
                                }.flush()
                            }
                        }
                    }
                }
            }
            val originalLabel = originalApplicationInfo.loadLabel(packageManager)
            val originalIcon: Drawable? = originalApplicationInfo.loadIcon(packageManager)
            Log.d(
                "AppLog",
                "originalPackageInfo: label:$originalLabel appIcon:${originalIcon?.javaClass?.simpleName}"
            )
            //
            val packageArchiveInfo =
                packageManager.getPackageArchiveInfo(outputFile.absolutePath, 0)
            val label = packageArchiveInfo?.applicationInfo?.loadLabel(packageManager)?.toString()
            val appIcon = packageArchiveInfo?.applicationInfo?.loadIcon(packageManager)
            Log.d(
                "AppLog",
                "packageArchiveInfo!=null?${packageArchiveInfo != null} label:$label appIcon:${appIcon?.javaClass?.simpleName}"
            )
        }
    }
}
Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • Comments are not for extended discussion; this conversation has been [moved to chat](https://chat.stackoverflow.com/rooms/216105/discussion-on-answer-by-cheticamp-how-to-parse-an-apk-after-converting-it-to-a-m). – Samuel Liew Jun 17 '20 at 02:40