feat(packages): 支持AAB/APKS/APKM/XAPK格式安装包解析

新增AndroidPackageTypeDetector用于识别安装包类型,扩展ApkMetaParser以支持AAB、APKS、APKM、XAPK等容器格式的元数据解析,并在上传服务中集成解析逻辑,完善应用包信息提取能力。
This commit is contained in:
2026-08-07 19:54:16 +08:00
parent 8ee05af6c7
commit 3422d546c5
6 changed files with 624 additions and 37 deletions

View File

@@ -0,0 +1,68 @@
package com.youlai.boot.app.packages.component;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public class AndroidPackageTypeDetector {
// 第一层:判 ZIP 魔数
public enum PkgType { APK, AAB, APKS, APKM, XAPK, UNKNOWN }
public PkgType detect(Path file) throws IOException {
// 1. 读前 4 字节判 ZIP
byte[] header = new byte[4];
try (InputStream in = Files.newInputStream(file)) {
int read = in.read(header);
if (read < 4) return PkgType.UNKNOWN;
}
// ZIP 魔数50 4B 03 04
if (!(header[0] == 0x50 && header[1] == 0x4B
&& header[2] == 0x03 && header[3] == 0x04)) {
return PkgType.UNKNOWN; // 不是 ZIP直接淘汰
}
// 2. 枚举 ZIP 条目,收集条目名(用 ZipFile 避免 ZipInputStream 对
// STORED 条目携带 EXT descriptor 标志时的严格校验异常)
Set<String> entries = new HashSet<>();
try (ZipFile zf = new ZipFile(file.toFile())) {
Enumeration<? extends ZipEntry> en = zf.entries();
while (en.hasMoreElements()) {
entries.add(en.nextElement().getName());
}
}
// 3. 按特征条目判定(注意顺序:先判容器,再判单包)
boolean hasTocPb = entries.contains("toc.pb");
boolean hasInfoJson = entries.contains("info.json");
boolean hasManifestJson = entries.contains("manifest.json");
boolean hasBaseDir = entries.stream().anyMatch(n -> n.startsWith("base/"));
boolean hasAndroidManifest = entries.contains("AndroidManifest.xml");
boolean hasClassesDex = entries.stream().anyMatch(n -> n.equals("classes.dex")
|| n.startsWith("classes"));
boolean hasBundleMetadata = entries.stream()
.anyMatch(n -> n.startsWith("BUNDLE-METADATA/"));
boolean hasObIn = entries.stream().anyMatch(n -> n.startsWith("Android/obb/"));
// 4. 决策树
if (hasTocPb) {
return PkgType.APKS; // toc.pb 是 APKS 独有
}
if (hasInfoJson && !hasManifestJson) {
return PkgType.APKM; // info.json 且无 manifest.json → APKM
}
if (hasManifestJson) {
return PkgType.XAPK; // manifest.json → XAPK
}
if (hasBaseDir && hasBundleMetadata && !hasClassesDex) {
return PkgType.AAB; // 模块化 + BUNDLE-METADATA → AAB
}
if (hasAndroidManifest && hasClassesDex) {
return PkgType.APK; // 根级 AndroidManifest + classes.dex → APK
}
return PkgType.UNKNOWN;
}
}

View File

@@ -8,18 +8,29 @@ import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* APK 安装包元数据解析器(基于 apk-parser
* Android 安装包元数据解析器(基于 apk-parser + zip4j
* <p>
* 用于从 apk 文件中尽可能详细地读取包名、版本、SDK 版本、权限、CPU 架构、图标等信息。
* aab / xapk / apks / apkm 为其他格式,本解析器不适用,由上传流程按文件基础信息处理。
* 支持五种格式:
* <ul>
* <li>APK单包直接用 apk-parser 解析(最成熟)</li>
* <li>AABZIP 容器,内部为 base/manifest/AndroidManifest.xml 等模块结构,
* 解包后用 apk-parser 解析二进制 Manifest兜底bundletool 命令行)</li>
* <li>APKS/APKM/XAPKZIP 容器,内部包含一个或多个 APK 及描述文件,
* 解包后定位主 APK复用 APK 解析逻辑,再补充 JSON 描述信息</li>
* </ul>
*/
@Component
public class ApkMetaParser {
@@ -38,54 +49,374 @@ public class ApkMetaParser {
private List<String> permissions = new ArrayList<>();
private String iconPath;
private byte[] iconBytes;
/** 容器格式apks/apkm/xapk内解析出的 split apk 列表,便于审计 */
private List<String> splitApkList = new ArrayList<>();
}
/**
* 解析 apk 文件元数据
* 统一解析入口:根据文件扩展名分发到对应解析策略。
*/
public ApkMetaResult parse(File apkFile) throws IOException {
public ApkMetaResult parse(File packageFile) throws IOException {
String name = packageFile.getName().toLowerCase(Locale.ROOT);
int dot = name.lastIndexOf('.');
String ext = dot >= 0 ? name.substring(dot + 1) : "";
return switch (ext) {
case "apk" -> parseApk(packageFile);
case "aab" -> parseAab(packageFile);
case "apks", "apkm", "xapk" -> parseContainer(packageFile);
default -> throw new IllegalArgumentException("不支持的安装包格式: ." + ext);
};
}
// ============================ APK ============================
/**
* 解析 APK 单包apk-parser 解析二进制 AndroidManifest.xml
*/
public ApkMetaResult parseApk(File apkFile) throws IOException {
ApkMetaResult result = new ApkMetaResult();
try (ApkFile apkFileParser = new ApkFile(apkFile)) {
apkFileParser.setPreferredLocale(Locale.SIMPLIFIED_CHINESE);
ApkMeta meta = apkFileParser.getApkMeta();
result.setPackageName(meta.getPackageName());
result.setAppLabel(meta.getLabel());
result.setVersionName(meta.getVersionName());
result.setVersionCode(toInt(meta.getVersionCode()));
result.setMinSdk(toInt(meta.getMinSdkVersion()));
result.setTargetSdk(toInt(meta.getTargetSdkVersion()));
result.setCompileSdk(toInt(meta.getCompileSdkVersion()));
result.setMaxSdk(toInt(meta.getMaxSdkVersion()));
if (meta.getUsesPermissions() != null) {
result.setPermissions(new ArrayList<>(meta.getUsesPermissions()));
}
// CPU 架构:扫描 lib/<abi>/ 目录
fillFromApkMeta(result, meta);
result.setAbiList(extractAbiList(apkFile));
// 图标:优先 launcher 图标,取首个图标的字节
try {
List<Icon> icons = apkFileParser.getIconFiles();
if (icons != null && !icons.isEmpty()) {
Icon icon = icons.get(0);
result.setIconPath(icon.getPath());
byte[] data = icon.getData();
if (data != null && data.length > 0) {
result.setIconBytes(data);
}
}
} catch (Exception ignored) {
// 图标解析失败不影响其它字段
}
fillIcon(result, apkFileParser);
}
return result;
}
// ============================ AAB ============================
/**
* 解析 AABAndroid App Bundle
* <p>
* 策略AAB 本质是一个 ZIP解包后读取 base/manifest/AndroidManifest.xml 这个二进制 XML
* 用 apk-parser 的 ApkFile 直接解析2.6.x 兼容 AAB 内部文件)。
* 若 apk-parser 解析失败,则尝试调用 bundletool 命令行 dump manifest 兜底。
*/
public ApkMetaResult parseAab(File aabFile) throws IOException {
// 优先:用 apk-parser 直接读取(它能识别 AAB 内的模块结构)
try (ApkFile apkFileParser = new ApkFile(aabFile)) {
apkFileParser.setPreferredLocale(Locale.SIMPLIFIED_CHINESE);
ApkMeta meta = apkFileParser.getApkMeta();
ApkMetaResult result = new ApkMetaResult();
fillFromApkMeta(result, meta);
// AAB 本身不含 lib/<abi> 与图标资源(图标在运行时生成),此处跳过
return result;
} catch (Exception e) {
// 兜底bundletool 命令行解析
return parseAabByBundleTool(aabFile);
}
}
/**
* 兜底方案:调用 Google 官方 bundletool 输出 AndroidManifest.xml 文本并提取关键字段。
* 需要环境中存在 bundletool.jar可通过 -Dbundletool.jar 指定路径)。
*/
private ApkMetaResult parseAabByBundleTool(File aabFile) throws IOException {
String jarPath = System.getProperty("bundletool.jar");
if (jarPath == null || jarPath.isBlank()) {
throw new IOException("apk-parser 解析 AAB 失败,且未配置 bundletool.jar 兜底");
}
try {
Process process = new ProcessBuilder(
"java", "-jar", jarPath, "dump", "manifest",
"--bundle=" + aabFile.getAbsolutePath()
).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
int exit = process.waitFor();
if (exit != 0) {
throw new IOException("bundletool 执行失败: " + output);
}
return parseManifestXmlText(output);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("bundletool 执行被中断", ie);
}
}
// ============================ APKS / APKM / XAPK ============================
/**
* 解析 ZIP 容器格式APKS / APKM / XAPK
* <p>
* 通用流程:
* 1. 用 zip4j / java.util.zip 解包,收集所有 .apk 及描述文件manifest.json / info.json
* 2. 定位主 APKbase-master.apk / base.apk / 第一个能解析出 packageName 的)
* 3. 复用 APK 解析逻辑
* 4. 补充解析 JSON 描述文件obb / split 列表等)
*/
public ApkMetaResult parseContainer(File containerFile) throws IOException {
List<ContainerEntry> apks = new ArrayList<>();
byte[] manifestJson = null;
byte[] infoJson = null;
try (ZipFile zf = new ZipFile(containerFile)) {
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("manifest.json")) {
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;
}
/**
* 经验法则定位主 APK
* <ul>
* <li>APKSGoogle 官方 split 集base-master.apk 或 base.apk</li>
* <li>XAPKAPKPure与包名对应的主 apk通常不含 split 关键字)</li>
* <li>APKMAPKMirrorinfo.json 描述的 base.apk</li>
* </ul>
*/
private ContainerEntry locateMainApk(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;
}
}
}
// 再找不含 split / config / density 等关键字的主包
for (ContainerEntry e : apks) {
String lower = e.name.toLowerCase(Locale.ROOT);
if (!lower.contains("split") && !lower.contains("config") && !lower.contains("density")) {
return e;
}
}
return null;
}
// ============================ 通用工具 ============================
private record ContainerEntry(String name, byte[] data) {}
/**
* 从字节数组解析 APK用于容器内的主 APK
*/
private ApkMetaResult tryParseApkBytes(byte[] apkBytes) {
// ApkFile 仅支持 File / String 路径,故将容器内主 APK 字节落盘为临时文件解析
File tmp = null;
try {
tmp = File.createTempFile("apk-parser-", ".apk");
Files.write(tmp.toPath(), apkBytes);
try (ApkFile apkFileParser = new ApkFile(tmp)) {
apkFileParser.setPreferredLocale(Locale.SIMPLIFIED_CHINESE);
ApkMeta meta = apkFileParser.getApkMeta();
ApkMetaResult result = new ApkMetaResult();
fillFromApkMeta(result, meta);
return result;
}
} catch (Exception e) {
return null;
} finally {
if (tmp != null && tmp.exists()) {
tmp.delete();
}
}
}
private void fillFromApkMeta(ApkMetaResult result, ApkMeta meta) {
result.setPackageName(meta.getPackageName());
result.setAppLabel(meta.getLabel());
result.setVersionName(meta.getVersionName());
result.setVersionCode(toInt(meta.getVersionCode()));
result.setMinSdk(toInt(meta.getMinSdkVersion()));
result.setTargetSdk(toInt(meta.getTargetSdkVersion()));
result.setCompileSdk(toInt(meta.getCompileSdkVersion()));
result.setMaxSdk(toInt(meta.getMaxSdkVersion()));
if (meta.getUsesPermissions() != null) {
result.setPermissions(new ArrayList<>(meta.getUsesPermissions()));
}
}
private void fillIcon(ApkMetaResult result, ApkFile apkFileParser) {
try {
List<Icon> icons = apkFileParser.getIconFiles();
if (icons != null && !icons.isEmpty()) {
Icon icon = icons.get(0);
result.setIconPath(icon.getPath());
byte[] data = icon.getData();
if (data != null && data.length > 0) {
result.setIconBytes(data);
}
}
} catch (Exception ignored) {
// 图标解析失败不影响其它字段
}
}
/**
* 从 bundletool 输出的 AndroidManifest.xml 文本中提取关键字段。
*/
private ApkMetaResult parseManifestXmlText(String manifestXml) {
ApkMetaResult result = new ApkMetaResult();
// 包名:<manifest ... package="xxx">
result.setPackageName(attr(manifestXml, "package"));
// 应用名:<application ... android:label="xxx">
result.setAppLabel(attr(manifestXml, "android:label"));
// 版本名 / 版本号
result.setVersionName(attr(manifestXml, "android:versionName"));
result.setVersionCode(toInt(attr(manifestXml, "android:versionCode")));
// SDK 版本:<uses-sdk android:minSdkVersion=".." android:targetSdkVersion=".." android:compileSdkVersion=".."/>
result.setMinSdk(toInt(attr(manifestXml, "android:minSdkVersion")));
result.setTargetSdk(toInt(attr(manifestXml, "android:targetSdkVersion")));
result.setCompileSdk(toInt(attr(manifestXml, "android:compileSdkVersion")));
result.setMaxSdk(toInt(attr(manifestXml, "android:maxSdkVersion")));
return result;
}
private String attr(String xml, String name) {
// 兼容 package="x" 与 android:label="x" 两种写法
String pattern = name + "=\"";
int idx = xml.indexOf(pattern);
if (idx < 0) {
return null;
}
int start = idx + pattern.length();
int end = xml.indexOf('"', start);
if (end < 0) {
return null;
}
return xml.substring(start, end);
}
/**
* 从容器内的 JSON 描述文件补充信息XAPK 的 manifest.json / APKM 的 info.json
* 仅做轻量提取:包名 / 版本名 / 版本号缺失时回填。
*/
private void supplementFromJson(ApkMetaResult result, byte[] jsonBytes) {
String json = new String(jsonBytes, StandardCharsets.UTF_8);
if (result.getPackageName() == null || result.getPackageName().isBlank()) {
String pkg = jsonStringField(json, "package");
if (pkg != null) {
result.setPackageName(pkg);
}
}
if (result.getVersionName() == null || result.getVersionName().isBlank()) {
String vn = jsonStringField(json, "versionName");
if (vn == null) {
vn = jsonStringField(json, "version");
}
if (vn != null) {
result.setVersionName(vn);
}
}
if (result.getVersionCode() == null) {
Integer vc = jsonIntField(json, "versionCode");
if (vc == null) {
vc = jsonIntField(json, "version_code");
}
if (vc != null) {
result.setVersionCode(vc);
}
}
}
private String jsonStringField(String json, String key) {
// 简单匹配 "key" : "value" 或 "key":"value"
String pattern = "\"" + key + "\"\\s*:\\s*\"";
int idx = json.indexOf(pattern);
if (idx < 0) {
return null;
}
int start = idx + pattern.length();
int end = json.indexOf('"', start);
if (end < 0) {
return null;
}
return json.substring(start, end);
}
private Integer jsonIntField(String json, String key) {
String pattern = "\"" + key + "\"\\s*:\\s*";
int idx = json.indexOf(pattern);
if (idx < 0) {
return null;
}
int start = idx + pattern.length();
int end = start;
while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) {
end++;
}
if (end == start) {
return null;
}
try {
return Integer.parseInt(json.substring(start, end));
} catch (NumberFormatException e) {
return null;
}
}
/**
* 扫描 apk 内 lib/<abi>/ 目录,提取支持的 CPU 架构列表。
*/
private List<String> extractAbiList(File apkFile) {
Set<String> abis = new LinkedHashSet<>();
try (ZipFile zf = new ZipFile(apkFile)) {
java.util.Enumeration<? extends ZipEntry> entries = zf.entries();
Enumeration<? extends ZipEntry> entries = zf.entries();
while (entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if (name.startsWith("lib/") && name.length() > 4) {

View File

@@ -112,8 +112,35 @@ public class AppPackageServiceImpl implements AppPackageService {
fillFallback(builder, packageName, versionName, versionCode);
}
} else {
// aab / xapk / apks / apkm 暂不支持二进制解析,使用表单补充信息
fillFallback(builder, packageName, versionName, versionCode);
// aab / xapk / apks / apkm:统一交由解析器解包内部 APK
try {
ApkMetaParser.ApkMetaResult meta = apkMetaParser.parse(storedFile);
builder.packageName(firstNonBlank(meta.getPackageName(), packageName))
.appLabel(meta.getAppLabel())
.versionName(firstNonBlank(meta.getVersionName(), versionName))
.versionCode(orNull(meta.getVersionCode(), versionCode))
.minSdk(meta.getMinSdk())
.targetSdk(meta.getTargetSdk())
.compileSdk(meta.getCompileSdk())
.abiList(meta.getAbiList())
.permissions(meta.getPermissions());
if (meta.getIconPath() != null && meta.getIconBytes() != null && meta.getIconBytes().length > 0) {
String iconMd5 = DigestUtil.md5Hex(meta.getIconBytes());
String iconName = iconMd5 + ".png";
File iconDir = new File(FilePath.getApkIconPath());
if (!iconDir.exists()) {
iconDir.mkdirs();
}
File iconTarget = new File(iconDir, iconName);
if (!iconTarget.exists()) {
Files.write(iconTarget.toPath(), meta.getIconBytes());
}
builder.iconUrl("/static/app_icon/" + iconName);
}
} catch (Exception e) {
log.warn("{} 元数据解析失败,使用表单补充信息:{}", ext, e.getMessage());
fillFallback(builder, packageName, versionName, versionCode);
}
}
List<String> categoryList = (categories != null) ? categories : List.of();

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.common.util;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Paths;
public class FileUtils {
public static String getMagicBytes(String filePath, int position, int bytes) {
try {
byte[] data = Files.readAllBytes(Paths.get(filePath));
ByteBuffer bb = ByteBuffer.wrap(data);
bb.position(position);
StringBuilder sb = new StringBuilder();
for(int i=0; i<bytes; i++) {
sb.append(String.format("%02x", bb.get()));
}
return sb.toString();
}catch(IOException ex) {
ex.printStackTrace();
return null;
}
}
}