feat(packages): 重构 APK 容器解析,支持 APKM/XAPK/SAPK 格式

This commit is contained in:
2026-08-14 19:33:30 +08:00
parent 6005c454c1
commit baa2d8d2e3
3 changed files with 345 additions and 75 deletions

View File

@@ -1,16 +1,23 @@
package com.youlai.boot.app.packages.component;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.android.tools.build.bundletool.commands.DumpCommand;
import com.android.tools.build.bundletool.model.AndroidManifest;
import com.android.tools.build.bundletool.model.AppBundle;
import com.android.tools.build.bundletool.model.BundleModule;
import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoAttribute;
import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoElement;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.youlai.boot.app.packages.model.dto.ApkmJsonInfo;
import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import net.dongliu.apk.parser.bean.Icon;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zeroturnaround.zip.ZipUtil;
import java.io.*;
import java.nio.charset.StandardCharsets;
@@ -51,7 +58,9 @@ public class ApkMetaParser {
case "apk" -> parseApk(packageFile);
case "aab" -> parseAab(packageFile);
case "apks" -> parseApks(packageFile);
case "apkm", "xapk" -> parseContainer(packageFile);
case "apkm" -> parseApkm(packageFile);
case "xapk" -> parseXapk(packageFile);
case "sapk" -> parseSapk(packageFile);
default -> throw new IllegalArgumentException("不支持的安装包格式: ." + ext);
};
}
@@ -296,7 +305,6 @@ public class ApkMetaParser {
return null;
}
// ============================ APKS ============================
/**
@@ -305,6 +313,7 @@ public class ApkMetaParser {
* bundletool 依赖可直接解析 .apks 整包并 dump 其 Manifest同时枚举 ZIP 内
* 的 apk 条目记录 split 列表。
*/
@Deprecated
public static ApkMetaResult parseApks(File apksFile) throws IOException {
ApkMetaResult result = parseByBundleTool(apksFile);
result.setSplitApkList(collectApkEntries(apksFile));
@@ -313,25 +322,233 @@ public class ApkMetaParser {
return result;
}
// ============================ APKM / XAPK ============================
// ============================ APKM ============================
/**
* 解析 ZIP 容器格式APKM / XAPK
* 解析 APKMAPKMirror 拆分包容器)格式
* <p>
* 这两类为第三方容器(非 Google 标准 bundlebundletool 无法识别,故沿用
* 解包方案:
* APKM 本质是 ZIP 容器,内含多个 split apk 及描述文件info.json
* <ol>
* <li>解包收集所有 .apk 及描述文件manifest.json / info.json</li>
* <li>解包收集所有 .apk 及描述文件</li>
* <li>定位主 APK复用 APK 解析逻辑</li>
* <li>补充解析 JSON 描述文件</li>
* </ol>
* <p>
* APKM_installer.url 安装器快捷链接
* icon.png 应用图标
* info.json 包元数据(版本/架构/语言/最低API等
* base.apk 基础主包,核心代码与资源,必装
* META-INF/ 签名信息(.RSA证书 / .SF摘要 / .MF清单
* split_*.apk 拆分包,可选安装,按需下载
*
*/
public static ApkMetaResult parseContainer(File containerFile) throws IOException {
public static ApkMetaResult parseApkm(File apkmFile) throws IOException {
ApkMetaResult apkMetaResult = new ApkMetaResult();
boolean baseApkExists = ZipUtil.containsEntry(apkmFile, "base.apk");
boolean infoExists = ZipUtil.containsEntry(apkmFile, "info.json");
boolean iconExists = ZipUtil.containsEntry(apkmFile, "icon.png");
if (!baseApkExists || !infoExists || !iconExists) {
throw new IOException("容器内未找到 base.apk 文件: " + apkmFile.getName());
}
//以上3个应该都存在,否则解析失败
byte[] infoData;
byte[] iconBytes;
if (infoExists) {
infoData = ZipUtil.unpackEntry(apkmFile, "info.json");
ObjectMapper objectMapper = new ObjectMapper();
ApkmJsonInfo info = objectMapper.readValue(infoData, ApkmJsonInfo.class);
apkMetaResult.setAppLabel(info.getApp_name());
apkMetaResult.setPackageName(info.getPname());
apkMetaResult.setVersionName(info.getRelease_version());
apkMetaResult.setVersionCode(info.getVersioncode());
apkMetaResult.setMinSdk(info.getMin_api());
apkMetaResult.setAbiList(info.getArches());
//info.json 无法获取到 targetSdk,compileSdk,permissions,iconPath
//先加载icon.png兜底,后续再加载base.apk,有些base.apk无法获取到图标
iconBytes = ZipUtil.unpackEntry(apkmFile, "icon.png");
apkMetaResult.setIconBytes(iconBytes);
apkMetaResult.setIconPath("icon.png");
byte[] baseApkbytes = ZipUtil.unpackEntry(apkmFile, "base.apk");
ApkMetaResult result = tryParseApkBytes(baseApkbytes);
if (result != null) {
if (!Objects.equals(apkMetaResult.getAppLabel(),result.getAppLabel())){
apkMetaResult.setAppLabel(result.getAppLabel());
}
if (!Objects.equals(apkMetaResult.getPackageName(),result.getPackageName())){
apkMetaResult.setPackageName(result.getPackageName());
}
if (!Objects.equals(apkMetaResult.getVersionName(),result.getVersionName())){
apkMetaResult.setVersionName(result.getVersionName());
}
if (!Objects.equals(apkMetaResult.getVersionCode(), result.getVersionCode())){
apkMetaResult.setVersionCode(result.getVersionCode());
}
//这三个属性无法从info.json中获取,只能从base.apk中获取
apkMetaResult.setTargetSdk(result.getTargetSdk());
apkMetaResult.setCompileSdk(result.getCompileSdk());
apkMetaResult.setPermissions(result.getPermissions());
if (result.getAbiList()!= null && !result.getAbiList().isEmpty()){
apkMetaResult.setAbiList(result.getAbiList());
}
if(result.getSplitApkList()!= null && !result.getSplitApkList().isEmpty()){
apkMetaResult.setSplitApkList(result.getSplitApkList());
}
if (StringUtils.isNotBlank(result.getIconPath())) {
apkMetaResult.setIconPath(result.getIconPath());
}
if (result.getIconBytes() != null) {
apkMetaResult.setIconBytes(result.getIconBytes());
}
}
return apkMetaResult;
} else {
ApkmContent content = readApkmContent(apkmFile);
List<ContainerEntry> apks = content.apks;
byte[] infoJson = content.infoJson;
if (apks.isEmpty()) {
throw new IOException("容器内未找到任何 .apk 文件: " + apkmFile.getName());
}
byte[] baseApkbytes = ZipUtil.unpackEntry(apkmFile, "base.apk");
// 1) 按经验法则优先定位主 APK
ContainerEntry mainApk = locateApkmMainApk(apks);
// 2) 若经验法则未命中,兜底:逐个尝试解析,第一个拿到 packageName 的即为主包
ApkMetaResult result = tryParseApkBytes(baseApkbytes);
if (result == null) {
for (ContainerEntry entry : apks) {
ApkMetaResult r = tryParseApkBytes(entry.data);
if (r != null && r.getPackageName() != null && !r.getPackageName().isBlank()) {
result = r;
mainApk = entry;
break;
}
}
}
if (result == null) {
throw new IOException("容器内所有 apk 均无法解析出有效包名: " + apkmFile.getName());
}
// 3) 记录 split apk 列表
for (ContainerEntry entry : apks) {
result.getSplitApkList().add(entry.name);
}
if (infoJson != null) {
supplementFromJson(result, infoJson);
}
return result;
}
}
/**
* 解包 ZIP 容器,收集内部所有 .apk 及描述文件info.json
* APKM固定为info.json
* <p>
*
*/
private static ApkmContent readApkmContent(File apkmFile) throws IOException {
List<ContainerEntry> apks = new ArrayList<>();
byte[] infoJson = null;
try (ZipFile zf = new ZipFile(apkmFile)) {
Enumeration<? extends ZipEntry> entries = zf.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (entry.isDirectory()) {
continue;
}
String entryName = entry.getName();
if (entryName.toLowerCase(Locale.ROOT).endsWith(".apk")) {
try (InputStream in = zf.getInputStream(entry)) {
apks.add(new ContainerEntry(entryName, in.readAllBytes()));
}
} else if (entryName.equalsIgnoreCase("info.json")) {
try (InputStream in = zf.getInputStream(entry)) {
infoJson = in.readAllBytes();
}
}
}
}
return new ApkmContent(apks, infoJson);
}
/**
* ZIP 容器解包结果:内部 .apk 条目集合 + 描述文件字节。
*/
private record ApkmContent(List<ContainerEntry> apks, byte[] infoJson) {
}
// ============================ XAPK ============================
/**
* 解析 XAPKAPKPure 拆分包容器)格式。
* <p>
* XAPK 与 APKM 结构类似,但主 APK 与 split 列表由 manifest.json 的
* {@code split_apks} 数组描述(主包为 {@code id == "base"} 对应的 apk
* 可能额外包含大型 obb 扩展文件(非 apk直接忽略
*/
public static ApkMetaResult parseXapk(File xapkFile) throws IOException {
XapkContent content = readXapkContent(xapkFile);
List<ContainerEntry> apks = content.apks;
byte[] manifestJson = content.manifestJson;
if (apks.isEmpty()) {
throw new IOException("容器内未找到任何 .apk 文件: " + xapkFile.getName());
}
// 1) XAPK 主包优先按 manifest.json 的 split_apks(id == "base") 定位,失败再走经验法则
ContainerEntry mainApk = locateXapkMainApk(apks, manifestJson);
// 2) 若经验法则未命中,兜底:逐个尝试解析,第一个拿到 packageName 的即为主包
ApkMetaResult result = mainApk != null ? tryParseApkBytes(mainApk.data) : null;
if (result == null) {
for (ContainerEntry entry : apks) {
ApkMetaResult r = tryParseApkBytes(entry.data);
if (r != null && r.getPackageName() != null && !r.getPackageName().isBlank()) {
result = r;
mainApk = entry;
break;
}
}
}
if (result == null) {
throw new IOException("容器内所有 apk 均无法解析出有效包名: " + xapkFile.getName());
}
// 3) 记录 split apk 列表
for (ContainerEntry entry : apks) {
result.getSplitApkList().add(entry.name);
}
// 4) 补充 JSON 描述信息XAPK 通常只有 manifest.json
if (manifestJson != null) {
supplementFromJson(result, manifestJson);
}
return result;
}
private static XapkContent readXapkContent(File xapkFile) throws IOException {
List<ContainerEntry> apks = new ArrayList<>();
byte[] manifestJson = null;
byte[] infoJson = null;
try (ZipFile zf = new ZipFile(containerFile)) {
try (ZipFile zf = new ZipFile(xapkFile)) {
Enumeration<? extends ZipEntry> entries = zf.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
@@ -347,71 +564,23 @@ public class ApkMetaParser {
try (InputStream in = zf.getInputStream(entry)) {
manifestJson = in.readAllBytes();
}
} else if (entryName.equalsIgnoreCase("info.json")) {
try (InputStream in = zf.getInputStream(entry)) {
infoJson = in.readAllBytes();
}
}
}
}
if (apks.isEmpty()) {
throw new IOException("容器内未找到任何 .apk 文件: " + containerFile.getName());
}
// 1) 按经验法则优先定位主 APK
ContainerEntry mainApk = locateMainApk(apks);
// 2) 若经验法则未命中,兜底:逐个尝试解析,第一个拿到 packageName 的即为主包
ApkMetaResult result = null;
if (mainApk != null) {
result = tryParseApkBytes(mainApk.data);
}
if (result == null) {
for (ContainerEntry entry : apks) {
ApkMetaResult r = tryParseApkBytes(entry.data);
if (r != null && r.getPackageName() != null && !r.getPackageName().isBlank()) {
result = r;
mainApk = entry;
break;
}
}
}
if (result == null) {
throw new IOException("容器内所有 apk 均无法解析出有效包名: " + containerFile.getName());
}
// 3) 记录 split apk 列表
for (ContainerEntry entry : apks) {
result.getSplitApkList().add(entry.name);
}
// 4) 补充 JSON 描述信息(如 XAPK 的 obb 文件名、APKM 的 split 列表)
if (manifestJson != null) {
supplementFromJson(result, manifestJson);
}
if (infoJson != null) {
supplementFromJson(result, infoJson);
}
return result;
return new XapkContent(apks, manifestJson);
}
/**
* 经验法则定位主 APK
* <ul>
* <li>APKSGoogle 官方 split 集base-master.apk 或 base.apk</li>
* <li>XAPKAPKPure与包名对应的主 apk通常不含 split 关键字)</li>
* <li>APKMAPKMirrorinfo.json 描述的 base.apk</li>
* </ul>
*/
private static ContainerEntry locateMainApk(List<ContainerEntry> apks) {
private static ContainerEntry locateApkmMainApk(List<ContainerEntry> apks) {
// 优先 base-master.apk / base.apk
for (String candidate : new String[]{"base-master.apk", "base.apk"}) {
for (ContainerEntry e : apks) {
if (e.name.toLowerCase(Locale.ROOT).endsWith(candidate)) {
return e;
}
for (ContainerEntry e : apks) {
if (e.name.toLowerCase(Locale.ROOT).equalsIgnoreCase("base.apk")) {
return e;
}
}
// 再找不含 split / config / density 等关键字的主包
@@ -424,6 +593,57 @@ public class ApkMetaParser {
return null;
}
/**
* XAPK 专属定位:解析 manifest.json 的 {@code split_apks} 数组,返回
* {@code id == "base"} 项对应的 apk 条目(即主包)。
*/
private static ContainerEntry locateXapkMainApk(List<ContainerEntry> apks, byte[] manifestJson) {
if (manifestJson == null) {
return null;
}
String baseFile = null;
try {
JSONArray splits = JSONUtil.parseObj(new String(manifestJson, StandardCharsets.UTF_8))
.getJSONArray("split_apks");
if (splits != null) {
for (int i = 0; i < splits.size(); i++) {
JSONObject split = splits.getJSONObject(i);
if ("base".equals(split.getStr("id"))) {
baseFile = split.getStr("file");
break;
}
}
}
} catch (Exception ignored) {
// JSON 解析失败则回退到经验法则
}
if (baseFile == null) {
return null;
}
for (ContainerEntry e : apks) {
if (e.name.equalsIgnoreCase(baseFile)) {
return e;
}
}
return null;
}
private record XapkContent(List<ContainerEntry> apks, byte[] manifestJson) {
}
/**
* 还没找到安装包
*
* @param xapkFile xapk 文件
* @return
*/
@Deprecated
private static ApkMetaResult parseSapk(File xapkFile) {
ApkMetaResult apkMetaResult = new ApkMetaResult();
return apkMetaResult;
}
// ============================ bundletool 库解析 ============================
/**
@@ -708,6 +928,10 @@ public class ApkMetaParser {
String json = new String(jsonBytes, StandardCharsets.UTF_8);
if (result.getPackageName() == null || result.getPackageName().isBlank()) {
String pkg = jsonStringField(json, "package");
// XAPK manifest.json 使用 package_name 字段
if (pkg == null) {
pkg = jsonStringField(json, "package_name");
}
if (pkg != null) {
result.setPackageName(pkg);
}

View File

@@ -0,0 +1,45 @@
package com.youlai.boot.app.packages.model.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@Data
public class ApkmJsonInfo implements Serializable {
@Serial
private static final long serialVersionUID = 13567946548318947L;
private int apkm_version;
private String apk_title;
private String app_name;
private String release_version;
private String variant;
private String release_title;
private long versioncode;
private String pname;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date post_date;
private List<String> capabilities;
private List<String> languages;
private List<String> arches;
private List<String> dpis;
private int min_api;
private String accent_color;
private long apk_id;
private long release_id;
@Override
public String toString() {
try {
return new ObjectMapper().writeValueAsString(this);
} catch (JsonProcessingException e) {
return super.toString();
}
}
}

View File

@@ -89,17 +89,18 @@ class ApkMetaParserTest {
assertNotNull(result.getPackageName(), "包名不应为空");
System.out.println("===== APK 元数据 =====");
System.out.println("packageName : " + result.getPackageName());
System.out.println("appLabel : " + result.getAppLabel());
System.out.println("versionName : " + result.getVersionName());
System.out.println("versionCode : " + result.getVersionCode());
System.out.println("minSdk : " + result.getMinSdk());
System.out.println("targetSdk : " + result.getTargetSdk());
System.out.println("compileSdk : " + result.getCompileSdk());
System.out.println("abiList : " + result.getAbiList());
System.out.println("permissions : " + result.getPermissions());
System.out.println("iconPath : " + result.getIconPath());
System.out.println("iconBytes : " + (result.getIconBytes() == null
System.out.println("packageName : " + result.getPackageName());
System.out.println("appLabel : " + result.getAppLabel());
System.out.println("versionName : " + result.getVersionName());
System.out.println("versionCode : " + result.getVersionCode());
System.out.println("minSdk : " + result.getMinSdk());
System.out.println("targetSdk : " + result.getTargetSdk());
System.out.println("compileSdk : " + result.getCompileSdk());
System.out.println("abiList : " + result.getAbiList());
System.out.println("permissions : " + result.getPermissions());
System.out.println("splitApkList : " + result.getSplitApkList());
System.out.println("iconPath : " + result.getIconPath());
System.out.println("iconBytes : " + (result.getIconBytes() == null
? "null" : result.getIconBytes().length + " bytes"));
System.out.println("=====================");
success++;