feat(packages): 重构 APK 容器解析,支持 APKM/XAPK/SAPK 格式
This commit is contained in:
@@ -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 拆分包容器)格式。
|
||||
* <p>
|
||||
* 这两类为第三方容器(非 Google 标准 bundle),bundletool 无法识别,故沿用
|
||||
* 解包方案:
|
||||
* 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 ============================
|
||||
|
||||
/**
|
||||
* 解析 XAPK(APKPure 拆分包容器)格式。
|
||||
* <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>APKS(Google 官方 split 集):base-master.apk 或 base.apk</li>
|
||||
* <li>XAPK(APKPure):与包名对应的主 apk(通常不含 split 关键字)</li>
|
||||
* <li>APKM(APKMirror):info.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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user