From baa2d8d2e322978d1605240aacbac09ef56bfe61 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Fri, 14 Aug 2026 19:33:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(packages):=20=E9=87=8D=E6=9E=84=20APK=20?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E8=A7=A3=E6=9E=90=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20APKM/XAPK/SAPK=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/packages/component/ApkMetaParser.java | 352 ++++++++++++++---- .../app/packages/model/dto/ApkmJsonInfo.java | 45 +++ .../packages/component/ApkMetaParserTest.java | 23 +- 3 files changed, 345 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/youlai/boot/app/packages/model/dto/ApkmJsonInfo.java 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): *

    - *
  1. 解包收集所有 .apk 及描述文件(manifest.json / info.json)
  2. + *
  3. 解包收集所有 .apk 及描述文件
  4. *
  5. 定位主 APK,复用 APK 解析逻辑
  6. *
  7. 补充解析 JSON 描述文件
  8. *
+ *

+ * 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 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 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: *

*/ - 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++;