diff --git a/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java b/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java
index 83beca7d..40c88f50 100644
--- a/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java
+++ b/src/main/java/com/youlai/boot/app/packages/component/ApkMetaParser.java
@@ -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)。
+ * 解析 APKM(APKMirror 拆分包容器)格式。
*
- * 这两类为第三方容器(非 Google 标准 bundle),bundletool 无法识别,故沿用
- * 解包方案:
+ * APKM 本质是 ZIP 容器,内含多个 split apk 及描述文件(info.json):
*
- * - 解包收集所有 .apk 及描述文件(manifest.json / info.json)
+ * - 解包收集所有 .apk 及描述文件
* - 定位主 APK,复用 APK 解析逻辑
* - 补充解析 JSON 描述文件
*
+ *
+ * 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 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
+ *
+ *
+ */
+ private static ApkmContent readApkmContent(File apkmFile) throws IOException {
+ List 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 apks, byte[] infoJson) {
+ }
+
+ // ============================ XAPK ============================
+
+ /**
+ * 解析 XAPK(APKPure 拆分包容器)格式。
+ *
+ * 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 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 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:
*
- * - APKS(Google 官方 split 集):base-master.apk 或 base.apk
- * - XAPK(APKPure):与包名对应的主 apk(通常不含 split 关键字)
* - APKM(APKMirror):info.json 描述的 base.apk
*
*/
- private static ContainerEntry locateMainApk(List apks) {
+ private static ContainerEntry locateApkmMainApk(List 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 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 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);
}
diff --git a/src/main/java/com/youlai/boot/app/packages/model/dto/ApkmJsonInfo.java b/src/main/java/com/youlai/boot/app/packages/model/dto/ApkmJsonInfo.java
new file mode 100644
index 00000000..bed1b680
--- /dev/null
+++ b/src/main/java/com/youlai/boot/app/packages/model/dto/ApkmJsonInfo.java
@@ -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 capabilities;
+ private List languages;
+ private List arches;
+ private List 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();
+ }
+ }
+}
diff --git a/src/test/java/com/youlai/boot/app/packages/component/ApkMetaParserTest.java b/src/test/java/com/youlai/boot/app/packages/component/ApkMetaParserTest.java
index f921292f..62c1b667 100644
--- a/src/test/java/com/youlai/boot/app/packages/component/ApkMetaParserTest.java
+++ b/src/test/java/com/youlai/boot/app/packages/component/ApkMetaParserTest.java
@@ -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++;