Android源码:使用Compose开发一款桌面端APK逆向工具

1.前言

对《使用Compose Desktop开发一款桌面端多功能Apk工具》这篇文章还有印象吗,文章地址:https://juejin.cn/post/7122645579439538183,这是笔者目前阅读量最高的文章了,现在这款工具已经进行了多次升级并且移植到我们的CMS系统了,然而遗憾的是其跟公司APP端的业务绑定比较紧密导致最终也不适合开源出来。

2.小感慨

距离上次写文章过去小半年了,开年后一直在应用下架与上架之间反复横跳,隐私合规与违规之间来回游走,真的是焦头烂额。不过好在基本摸清了国内各大应用商店上架的相关规则,以及马甲包判定的相关原理。

马甲包这个东西,高情商的说法应该叫“APP矩阵”,像我这种情商比较低的就直呼为马甲包了。为什么会做马甲包,不瞒大家,社交直播类型的这种应用一不小心可能就踩坑被封了,虽然各种鉴黄、风控等基本设施都有了,但道高一尺魔高一丈,总会有一些漏网之鱼。再加上政策的收紧,所以多应用布局就成了必然了。

那么这种情况下有两个选择:

  • 1、像个乖孩子一样从头重新开发新的应用出来
  • 2、像个耍小聪明的孩子一样用Product Flavor的方式克隆应用出来

第一种方式费时费力,费钱费人,除非是新项目,否则一般公司绝对不会选择这么做。第二种方式简单粗暴,快速高效,是绝大部分人的首选,但是其缺点也很明显,代码、资源等文件重复度高,容易被判定为相似应用,从而被限流(OPPO)或者拒绝上架(HUAWEI),所以我们就需要一个与之对抗的策略。

上文说了这么多想必大家也能猜到我后续新的文章应该往哪个方向去了,但是呢,别着急一口气吃成胖子,我们简单点,先从APK文件的逆向讲起来,了解下逆向所需的工具,然后我们将其集成到我们自己的桌面端应用中。这个桌面应用的目标就是要做到能「解码APK」文件,能修改解码后的文件,例如修改应用名,应用图标等等简单的东西,最后再「重新打包」为新的APK文件,「对齐、重签名」即可。

3.逆向工具简介

3.1.ApkTool

GitHub地址:https://github.com/iBotPeaches/Apktool
网站地址:https://ibotpeaches.github.io/Apktool/

这个是目前我最常用的工具,它可以将APK文件解码为资源文件和smali代码等,我们可以编辑解码后的布局文件、图片、字符串等等资源,甚至可以修改smali代码。修改完成后,ApkTool还可以将这些文件重新打包成一个新的APK文件。

常用命令如下:

# 解码APK文件,可以使用decode命令
apktool decode old.apk -o outputDir

# 将解码后的文件构建为一个新的APK文件,可以使用build命令
apktool build outputDir -o new.apk

**注意:**重新构建好的APK文件已经丢失了签名信息,使用的话还需要进行对齐、签名操作。目前我们应用后期一些定制的功能的基本都会使用他。

3.2.Jadx

GitHub地址:https://github.com/skylot/jadx

对比ApkTool,Jadx能将APK、Dex文件等,直接反编译出来Java源代码。它同时提供了图形化的界面,命令行功能,以及相关依赖库。

在这次示例中我们直接集成了Jadx在Maven仓库中提供的依赖,具体方式GitHub中也有说明,有需要请参考https://github.com/skylot/jadx/wiki/Use-jadx-as-a-library:

// jadx依赖相关
commonMainImplementation("io.github.skylot:jadx-core:1.4.7")
commonMainImplementation("io.github.skylot:jadx-dex-input:1.4.7")

反编译Dex文件相关代码如下:

/**
* 使用Jadx将dex文件反编译为java源文件
*/
private fun dexFile2java(
    dexFile: File,
    outDirPath: String
) {
    val jadxArgs = JadxArgs()
    jadxArgs.setInputFile(dexFile)
    jadxArgs.outDir = File(outDirPath)
    try {
        JadxDecompiler(jadxArgs).use { jadx ->
            jadx.load()
            jadx.save()
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

3.3.其他工具

在实际应用呢我也是使用上面两者居多,其他还有一些小工具也都不错,给大家简单推荐一下。

3.3.1.Dex文件反编译为Jar文件

dex2jar

GitHub地址:https://github.com/pxb1988/dex2jar (已经有2年未更新了)

顾名思义,将Dex文件转换为Jar文件的工具。有一个缺点,当dex文件比较大的时候,反编译会经常性卡死。

下载下来后是一个压缩包,解压后有win上的.bat可执行文件,以及unix系统上的.sh可执行文件,以mac上使用为例:

# 将classes.dex文件反编译为output.jar
./d2j-dex2jar.sh -o output.jar classes.dex

3.3.2.Jar文件反编译为Java文件

JavaDecompiler

GitHub地址:https://github.com/java-decompiler  (已经有4年未更新了)
网站主页:http://java-decompiler.github.io/

将Jar文件反编译为Java文件的工具,有命令行工具JD-Core,有图形化页面JD-GUI,也提供了Maven依赖库。

Procyon

GitHub地址:https://github.com/mstrobel/procyon

根据大部分逆向工程师的对比,这个工具比较好用,常用命令如下(在更换为jadx前我使用的也是这款工具):

# 反编译input.jar文件到outputDir文件夹中
java -jar procyon-decompiler.jar -o outputDir input.jar

Fernflower

GitHub地址:https://github.com/fesh0r/fernflower

IDEA自带的反编译工具,也比较不错。但是需要自己使用gradle编译出来可执行的jar文件,常用命令如下:

# 反编译input.jar文件到outputDir文件夹中
java -jar fernflower.jar input.jar outputDir

CFR

GitHub地址:https://github.com/leibnitz27/cfr
网站主页:https://www.benf.org/other/cfr/

这个工具我本身并没有使用过,这里便不再过多介绍了,有兴趣的朋友可以自行查看下。

4.桌面端逆向APK应用的开发

接下来我们就需要使用Compose来开发桌面端APK逆向工具了,需要集成上文提及的ApkTool提供的可执行jar文件以及jadx的Maven仓库依赖。

4.1.文件拖拽

首先呢我们需要支持将APK文件拖动到我们的工具面板上,然后自动执行后续的流程。之前的文章也写过,不过文章发表后就发现了一个bug,添加了这个功能后导致调整应用窗口大小的功能失效了。不过后来 @virogu 读者反馈并给出了解决的方案:

@Composable
fun DropHerePanel(
    modifier: Modifier,
    composeWindow: ComposeWindow,
    onFileDrop: (List<File>) -> Unit
) {

    val component = remember {
        ComposePanel().apply {
            val target = object : DropTarget() {
                override fun drop(event: DropTargetDropEvent) {
                    event.acceptDrop(DnDConstants.ACTION_REFERENCE)
                    val dataFlavors = event.transferable.transferDataFlavors
                    dataFlavors.forEach {
                        if (it == DataFlavor.javaFileListFlavor) {
                            val list = event.transferable.getTransferData(it) as List<*>
                            list.map { filePath ->
                                File(filePath.toString())
                            }.also(onFileDrop)
                        }
                    }
                    event.dropComplete(true)
                }
            }
            dropTarget = target
            isOpaque = false
        }
    }

    val pane = remember {
        composeWindow.rootPane
    }

    Box(
        modifier = modifier
            .onPlaced {
                val x = it.positionInWindow().x.roundToInt()
                val y = it.positionInWindow().y.roundToInt()
                val width = it.size.width
                val height = it.size.height
                component.setBounds(x, y, width, height)
            },
        contentAlignment = Alignment.Center
    ) {

        Text(text = "请拖拽文件到这里哦", fontSize = 36.sp, color = textColor)

        DisposableEffect(true) {
            pane.add(component)
            onDispose {
                pane.remove(component)
            }
        }
    }
}

4.2.构造工程目录

在上一节中,我们实现了文件拖拽功能,当APK文件被拖拽到当前工程面板后,我们首先使用aapt2工具解析APK文件,获取到基本信息后构造工程根目录,例如“包名_版本号”。然后使用ApkTool解码APK文件到decode文件夹中,解码完毕后我们使用jadx将解码后的smali文件反编译为java源文件,并另外存储到decompiled_java文件夹中。如果需要查看dex文件的话,我们可以解压apk文件到decompress文件夹中,这样我们就造好了一个基本的工程Project目录,然后构造目录树交给Compose的LazyColumn来显示,构造目录树的过程我们可以使用file.walk()来遍历然后存储相应的信息。

为了更专注于查看我们需要的文件,我们也添加了工程目录类型切换功能,类似IDEA一样,当切换为Packages模式的时候,就会将java源文件显示在java目录中,资源文件显示在res目录中,smali文件目录也会显示出来,方便大家对比java和smali文件:

当然了,当项目结构树显示出来后,或者选中源码文件后,右侧代码编辑区域长度或者宽度会超出应用大小,如下图所示,所以需要支持横向滚动和纵向滚动功能:

这对Compose来说完全不是问题,我们可以直接封装一个简单的Composable函数出来,如下所示:

/**
 * 带有滚动条的面板
 * 支持横向滚动条,竖向滚动条
 */

@Composable
fun ScrollPanel(
    modifier: Modifier,
    verticalScrollStateAdapter: ScrollbarAdapter,
    horizontalScrollStateAdapter: ScrollbarAdapter,
    content: @Composable BoxScope.() -> Unit
) {

    Row(modifier = modifier) {

        Column(modifier = Modifier.fillMaxHeight().weight(1f)) {
            Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
                content()
            }

            HorizontalScrollbar(
                adapter = horizontalScrollStateAdapter,
                style = ScrollbarStyle(
                    minimalHeight = 16.dp,
                    thickness = 8.dp,
                    shape = RoundedCornerShape(4.dp),
                    hoverDurationMillis = 300,
                    unhoverColor = Color.White.copy(alpha = 0.20f),
                    hoverColor = Color.White.copy(alpha = 0.50f)
                ),
                modifier = Modifier.fillMaxWidth().background(color = Color.Transparent)
            )
        }

        VerticalScrollbar(
            adapter = verticalScrollStateAdapter,
            style = ScrollbarStyle(
                minimalHeight = 16.dp,
                thickness = 8.dp,
                shape = RoundedCornerShape(4.dp),
                hoverDurationMillis = 300,
                unhoverColor = Color.White.copy(alpha = 0.20f),
                hoverColor = Color.White.copy(alpha = 0.50f)
            ),
            modifier = Modifier.fillMaxHeight().background(color = Color.Transparent)
        )
    }
}

4.3.文件标签页

做这个标签页功能的时候涉及到了一个知识点【固有特性测量】,内容介绍请参考https://developer.android.google.cn/jetpack/compose/layouts/intrinsic-measurements?hl=zh-cn。
如下所示,当标题栏的长度长短不一的时候,我们需要现根据标题的具体长度来决定下方指示器的长度。

此时我们就可以利用Compose的固有特性测量来实现该效果,在最外层的Column中使用Modifier.width(intrinsicSize = IntrinsicSize.Max)修饰符来指定按照子项的最大宽度进行测量,子项TextField宽度为自适应,子项指示器(Box)的宽度为最大固有宽度。这样当文本的内容长度变化的时候,指示器的宽度也会随之进行变化,示例代码如下:

val textValue = remember {
    mutableStateOf("hello, this is intrinsic measurements sample.")
}

Column(
    modifier = Modifier.width(intrinsicSize = IntrinsicSize.Max)
        .padding(16.dp)
) {

    TextField(
        value = textValue.value,
        onValueChange = {
            textValue.value = it
        },
    )

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(3.dp)
            .clip(shape = RoundedCornerShape(50))
            .background(color = Color(0xFF3674f0))
    )
}

效果如下:

4.4.关键字高亮

本来做完java和smali文件的字符串显示后就打算做下一个功能了,然而代码显示出来后总觉的缺了点什么,和真正的IDE一对比才发现少了关键字高亮的功能,目前只针对Java关键字实现了高亮效果,如下所示:

以java来讲,关键字就有50多个,不可能分别匹配50多次然后找出来关键字的索引位置,这样效率就太低了。那么这里就需要一个高效的多模式匹配算法-【Aho-Corasick】,仅需一次遍历,就能匹配出所有的关键字(字符串)。至于算法原理,还请大家自行学习,这里我们直接依赖java中的Aho-Corasick算法库来实现我们的需求,目前采用的依赖库及版本如下:

commonMainImplementation("com.hankcs:aho-corasick-double-array-trie:1.2.2")

当匹配出来所有的关键字后,为了实现富文本样式,字体、颜色、下划线等效果,在「Text」中就可以通过「AnnotatedString」来处理,在TextField中,可以通过设置「VisualTransformation」参数来实现,该参数用于控制输入文本的可视化样式转换,可以用于实现各种文本变换效果,例如隐藏密码、格式化文本等。只会影响输入文本的可视化样式效果,并不影响实际的数据模型。代码如下:

TextField(
    value = textContent,
    // ...省略了其他参数设置
    onValueChange = {},
    visualTransformation = if (textType == "java") {
        JavaKeywordVisualTransformation
    } else {
        VisualTransformation.None
    }
)

实现Java关键字高亮的VisualTransformation类:

/**
 * java关键字高亮显示
 */
object JavaKeywordVisualTransformation : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val textString = text.text
        return TransformedText(toAnnotatedString(textString), OffsetMapping.Identity)
    }
}


/**
 * String转换为AnnotatedString
 */
private fun toAnnotatedString(string: String): AnnotatedString {

    val keywordColor = Color(0xFFCF8E6D)

    val treeMap = TreeMap()
    keywordList.forEach {
        treeMap[it] = it
    }

    // 构建 Aho-Corasick 自动机
    val acdat = AhoCorasickDoubleArrayTrie()
    acdat.build(treeMap)

    val stringLength = string.length

    return buildAnnotatedString {
        append(string)

        val hitList = acdat.parseText(string)
        hitList.forEach { hit: AhoCorasickDoubleArrayTrie.Hit ->
            val start = hit.begin
            val end = hit.end

            // 如果后一个字符不是空格,证明不是一个单独的单词,则跳过
            if (end < stringLength) {
                val char = string[end]
                if (!char.isWhitespace()) {
                    return@forEach
                }
            }

            addStyle(
                style = SpanStyle(color = keywordColor),
                start = start,
                end = end
            )

        }
    }
}

4.5.本地图片显示

要显示APK解码后的图片资源,我们需要从本机上加载图片资源,所以下面这种painterResource的方式就不适用了:

Image(
    painter = painterResource(项目resources文件夹中的图片文件,支持png、svg等格式),
    contentDescription = "",
)

可以换用loadImageBitmap(InputStream)的方式,示例如下:

// 本地图片文件
val imageFile = File(filePath)

// 加载本地图片
Image(
    bitmap = loadImageBitmap(imageFile.inputStream()),
    contentDescription = ""
)

4.6.修改APK解码后的资源

以这样一个场景为例,我们需要给某个APP抓包,然而Android7.0及以上抓包是会出现问题的,无法轻易抓取到Https请求的明文数据。这是因为在7.0有一个名为“Network Security Configuration”的安全功能,这里不再赘述。我们的目的就是在解码后的res/xml目录中添加一个network_security_config_debug.xml文件,内容大致如下:


<network-security-config>
  <base-config cleartextTrafficPermitted="true">
    <trust-anchors>
      <certificates src="system" overridePins="true" />
      <certificates src="user" overridePins="true" />
    trust-anchors>
  base-config>
network-security-config>

然后需要在清单文件中添加或者修改「application」节点下的的「android:networkSecurityConfig」属性为”@xml/network_security_config_debug”,以此来允许我们使用user目录下的证书来达到https抓包的目的。

XML文件的创建和修改功能我们使用dom4j来处理,首先添加依赖:

// xml文件解析
commonMainImplementation("org.dom4j:dom4j:2.1.3")

使用dom4j生成network_security_config_debug.xml文件的代码如下:

fun createNetWorkSecurityConfigXml(
    outputFilePath: String
) {

    val document = DocumentHelper.createDocument()

    val networkSecurityConfigElement = document.addElement("network-security-config")
    val baseConfigElement = networkSecurityConfigElement.addElement("base-config")
    baseConfigElement.addAttribute("cleartextTrafficPermitted""true")

    val trustAnchorsElement = baseConfigElement.addElement("trust-anchors")

    trustAnchorsElement.addElement("certificates")
        .addAttribute("src""system")
        .addAttribute("overridePins""true")

    trustAnchorsElement.addElement("certificates")
        .addAttribute("src""user")
        .addAttribute("overridePins""true")

 var xmlWriter: XMLWriter? = null
    var outputStream: FileOutputStream? = null
    try {
        val litterFile = File(outputFilePath)
        outputStream = FileOutputStream(litterFile)

        val outputFormat = OutputFormat.createPrettyPrint()
        outputFormat.encoding = "UTF-8"

        xmlWriter = XMLWriter(outputStream, outputFormat)
        xmlWriter.write(document)
    } catch (e: Throwable) {
        e.printStackTrace()
    } finally {
        xmlWriter?.close()
        outputStream?.close()
    }
}

修改清单文件的功能就交给大家脑补实现了,无非就是解析xml文件,查找到application节点,判断是否有android:networkSecurityConfig属性,有则改之,无则加之。

4.7.重新打包APK并对齐、签名

再次说明一下:首先大部分的功能都是基于jar文件或exe文件(Windowns上)或者其他可执行文件(Mac上),那么在Java/Kotlin中我们可以通过如下方式来调用这些外部程序,exec其实最终也是调用了ProcessBuilder,整体的原理就是如此:

//方式1
Runtime.getRuntime().exec(cmd)
    
//方式2
ProcessBuilder(cmd)

这里涉及到的就是安卓打包的相关知识了,需要使用到android sdk/build-tools下的相关工具了,例如zipalign、apksigner.jar等工具。有一点需要注意,官方解释如下:

Caution: You must use zipalign at a specific point in the build process. That point depends on which app-signing tool you use:

If you use apksigner, zipalign must be used before the APK file has been signed. If you sign your APK using apksigner and make further changes to the APK, its signature is invalidated.
If you use jarsigner (not recommended), zipalign must be used after the APK file has been signed.

因为我们使用apksigner.jar对APK文件进行签名,所以需要在签名前先使用zipalign进行对齐处理,基本命令如下(这里以Mac为例):

zipalign -p -f -v 4 对齐前的APK路径 对齐后的APK路径

使用ProcessBuilder()对APK文件对齐的方式就是如下这样,runCMD()方法我们下一小节再具体介绍:

fun alignApk(
    srcApkPath: String,
    outputApkPath: String
)Boolean {

    var isAlignSuccess = false
    val result = runCMD(
        localZipAlignPath(), // 拷贝到本地的zipalign文件,区分系统、架构
        "-p",
        "-f",
        "-v",
        "4",
        srcApkPath,
        outputApkPath,
        onLine = {
            logger("Align APK : $it")
            if (it.contains("Verification succesful")) {
                isAlignSuccess = true
            }
        }
    )
    return result == 0 && isAlignSuccess
}

对齐后,使用apksigner.jar进行签名的过程也同理,签名的命令如下:

apksigner sign --verbose --ks 签名文件路径 --ks-pass pass:${签名文件密码} --ks-key-alias 签名别名 --key-pass pass:${签名密码} --out 签名后的APK路径 签名前的APK路径

使用runCMD()方法执行代码如下:

    fun signApk(
        alignedApkPath: String,
        outputApkPath: String,
        keyStorePath:String,
        keyStorePassword:String,
        keyAlias:String,
        keyPassword:String
    )Boolean {
        var isSignSuccess = false

        val result = runCMD(
            "java",
            "-jar",
            localApkSignerJarPath(),  // 拷贝到本地的apksigner.jar文件
            "sign",
            "--verbose",
            "--ks",
            keyStorePath,
            "--ks-pass",
            "pass:${keyStorePassword}",
            "--ks-key-alias",
            keyAlias,
            "--key-pass",
            "pass:${keyPassword}",
            "--out",
            outputApkPath,
            alignedApkPath,
            onLine = {
                logger("Sign APK : $it")
                if (it.contains("Signed")) {
                    isSignSuccess = true
                }
            }
        )
        return result == 0 && isSignSuccess
    }

注意:为什么需要将resources中的相关文件拷贝到本地,在之前的文章中有提到,ComposeDesktop打包好后,这些资源会被统一打包到一个jar文件中去,无法直接执行,所以需要先将其拷贝出来到本地。

4.8.系统及架构的适配

正常情况下我们简单的对ProcessBuilder进行如下封装就可以执行大部分的命令或脚本了:

/**
 * 运行CMD指令(不区分系统环境)
 */
fun runCMD(
    vararg elements: String,
    directory: File? = null,
    onLine: (String) -> Unit
)Int {
    val cmdStringBuilder = StringBuilder()
    elements.forEach {
        cmdStringBuilder.append(it).append(" ")
    }
    val process = ProcessBuilder(*elements)
        .directory(directory)
        .redirectErrorStream(true)
        .start()
    val reader = BufferedReader(
        InputStreamReader(
            process.inputStream,
            Charset.forName("UTF-8")
        )
    )
    var line: String?
    while (reader.readLine().also { line = it } != null) {
        line?.let {
            onLine(it)
        }
    }
    return process.waitFor()
}

但是针对上一小节介绍的工具,我们会发现有的工具是区分系统和架构的,以对齐工具zipalign为例:Windows上应该用zipalign.exe,Mac上应该用zipalign可执行文件,但是Mac近年来推出的Apple芯片是ARM架构,所以针对ARM和AMD64等不同架构也要选择不同的zipalign文件。

这就要求我们先准备好这些不同架构、不同系统的资源,做到根据不同系统、架构进行加载适配(以下方法并不完全适配所有系统和架构,还需完善):

/**
 * 获取系统名,根据名称判断系统类型
 */
fun getSystem(): String {
    return System.getProperties().getProperty("os.name")
}

/**
 * 判断是否是mac
 */
fun isMac()Boolean {
    return getSystem().lowercase().contains("mac")
}

/**
 * 判断是否是windows
 */
fun isWindows()Boolean {
    return getSystem().lowercase().contains("windows")
}

/**
 * 获取cpu架构
 */
fun geChip(): String {
    return System.getProperties().getProperty("os.arch")
}

/**
 * 判断是否是arm架构
 */
fun isArm()Boolean {
    return geChip().lowercase().contains("aarch64")
}

/**
 * 判断是否是amd64架构
 */
fun isAmd64()Boolean {
    return geChip().lowercase().contains("amd64")
}

组织这些文件的时候,我们将文件按照如下方式放到了resources目录,如下所示:当需要执行这些文件的时候,我们根据系统类型及架构拷贝对应的工具到本地进行存储(只是针对常用的Mac和Win进行了适配,其他暂无)。

当执行这些可执行文件的时候,Mac和Linux上可以直接执行(前提是需要可执行权限),但是在Win上执行相关命令前,可能需要一个cmd /c的前缀才行,所以针对不同的系统,执行命令也需要做一层区分,如下所示:

/**
 * 运行CMD指令
 * 区分win和unix(Mac OS和Linux)环境,一般执行exe或者unix上的可执行文件
 *
 * @param winCMD win命令前缀
 * @param unixCMD unix命令前缀
 * @param cmdSuffix 统一的后缀,会拼接到前面的数组上
 */
fun runCMD(
    winCMD: Array<String>,
    unixCMD: Array<String>,
    cmdSuffix: Array<String> = emptyArray(),
    directory: File? = null,
    onLine: (String) -> Unit
): Int {

    val cmd = if (isWindows()) {
        winCMD
    } else {
        unixCMD
    } + cmdSuffix

    return runCMD(elements = cmd, directory = directory, onLine = onLine)
}

那么针对不同的系统,以gradlew命令打包的情况示例(很突兀的就切换到这里了,因为在我们的打包服务中有这样的情况),使用方式则如下所示:

runCMD(
    winCMD = arrayOf("cmd""/c""gradlew"),
    unixCMD = arrayOf("./gradlew"),
    cmdSuffix = arrayOf("assembleXxxRelease"),
    directory = targetDir,
    onLine = {
        
    }

5.其他问题一览

5.1.APK文件的解压缩

下面是一个常用的解压文件的代码示例,当APK文件较小的时候,解压正常,但是当一个APK文件比较大的时候,这时候解压就会出现异常情况,在注释中可以看到,zipEntry获取到的name结果一直是空的,导致直接退出了解压:

fun decompressByZip(
    zipFilePath: String,
    outputDirPath: String
) {
    val buffer = ByteArray(1024)

    try {

        val outputDir = File(outputDirPath)
        if (!outputDir.exists()) {
            outputDir.mkdirs()
        }

        val zipInputStream = ZipInputStream(File(zipFilePath).inputStream())
        var zipEntry: ZipEntry? = zipInputStream.nextEntry

        while (zipEntry != null) {

            // 有获取到结果为空字符串的情况
            if (zipEntry.name.isNullOrBlank()) {
                continue
            }

            val newFile = File(outputDirPath, zipEntry.name)

            if (zipEntry.isDirectory) {
                newFile.mkdirs()
            } else {
                val parentDir = newFile.parentFile
                if (parentDir != null && !parentDir.exists()) {
                    parentDir.mkdirs()
                }

                val bufferedOutputStream = BufferedOutputStream(FileOutputStream(newFile))
                var bytesRead: Int
                while (zipInputStream.read(buffer).also { bytesRead = it } != -1) {
                    bufferedOutputStream.write(buffer, 0, bytesRead)
                }
                bufferedOutputStream.close()
            }

            zipEntry = zipInputStream.nextEntry
        }

        zipInputStream.closeEntry()
        zipInputStream.close()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

最后可能是由于APK文件使用了非标准的压缩方法,所以换用了Apache Commons Compress,依赖库及版本如下:

commonMainImplementation("org.apache.commons:commons-compress:1.22")

解压代码示例如下:

fun decompressByZip(
    zipFilePath: String,
    outputDirPath: String
) {
    try {

        val outputDir = File(outputDirPath)
        if (!outputDir.exists()) {
            outputDir.mkdirs()
        }

        ZipFile(zipFilePath).use { zip ->
            zip.entries.asSequence().forEach { entry ->
                val entryFile = File(outputDir, entry.name)

                logger("decompress zip: ${entryFile.name}")

                // 确保父目录存在
                entryFile.parentFile?.mkdirs()

                if (entry.isDirectory) {
                    // 如果是目录,创建对应的目录
                    entryFile.mkdirs()
                } else {
                    // 如果是文件,将文件解压到目标目录
                    zip.getInputStream(entry).use { input ->
                        FileOutputStream(entryFile).use { output ->
                            input.copyTo(output)
                        }
                    }
                }
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

资源下载
下载价格免费
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.dandroid.cn/16700,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?