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 ============================
+
+ /**
+ * 解析 AAB(Android App Bundle)。
+ *
+ * 策略: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/ 与图标资源(图标在运行时生成),此处跳过
+ 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)。
+ *
+ * 通用流程:
+ * 1. 用 zip4j / java.util.zip 解包,收集所有 .apk 及描述文件(manifest.json / info.json)
+ * 2. 定位主 APK(base-master.apk / base.apk / 第一个能解析出 packageName 的)
+ * 3. 复用 APK 解析逻辑
+ * 4. 补充解析 JSON 描述文件(obb / split 列表等)
+ */
+ public ApkMetaResult parseContainer(File containerFile) throws IOException {
+ List 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:
+ *
+ * - APKS(Google 官方 split 集):base-master.apk 或 base.apk
+ * - XAPK(APKPure):与包名对应的主 apk(通常不含 split 关键字)
+ * - APKM(APKMirror):info.json 描述的 base.apk
+ *
+ */
+ private ContainerEntry locateMainApk(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;
+ }
+ }
+ }
+ // 再找不含 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 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();
+ // 包名:
+ result.setPackageName(attr(manifestXml, "package"));
+ // 应用名:
+ result.setAppLabel(attr(manifestXml, "android:label"));
+ // 版本名 / 版本号
+ result.setVersionName(attr(manifestXml, "android:versionName"));
+ result.setVersionCode(toInt(attr(manifestXml, "android:versionCode")));
+ // SDK 版本:
+ 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// 目录,提取支持的 CPU 架构列表。
+ */
private List extractAbiList(File apkFile) {
Set 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) {
diff --git a/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java b/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java
index 81a9eed0..84d59d35 100644
--- a/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java
+++ b/src/main/java/com/youlai/boot/app/packages/service/impl/AppPackageServiceImpl.java
@@ -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 categoryList = (categories != null) ? categories : List.of();
diff --git a/src/main/java/com/youlai/boot/common/util/FileUtils.java b/src/main/java/com/youlai/boot/common/util/FileUtils.java
new file mode 100644
index 00000000..c298d530
--- /dev/null
+++ b/src/main/java/com/youlai/boot/common/util/FileUtils.java
@@ -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
+ * 从 {@link #APK_FILE_LIST} 中读取本地 apk 文件路径,逐个解析并打印元数据。
+ * 也可通过系统属性 / 环境变量批量覆盖:
+ *
+ * - 系统属性(分号分隔): -Dapk.test.files=D:/a.apk;D:/b.apk
+ * - 环境变量(分号分隔): APK_TEST_FILES=D:/a.apk;D:/b.apk
+ *
+ * 若没有可解析的文件,测试会打印提示并跳过,不会失败。
+ */
+@DisplayName("APK 元数据解析器批量测试")
+class ApkMetaParserTest {
+
+ /**
+ * 待测试的本地 apk 文件列表(在此处填入本地路径即可批量测试)。
+ */
+ private static final List APK_FILE_LIST = Arrays.asList(
+ "D:\\apkTest\\com.google.android.youtube.apk",
+ "D:\\apkTest\\XwadOSHonor_V1.4.7.aab",
+ "D:\\apkTest\\XwadOSHonor_V1.4.7.apks",
+ "D:\\apkTest\\Twitter_v8.95.0-release.00_apkpure.com.xapk",
+ "D:\\apkTest\\com.instagram.android_440.1.0.46.86-384611414_1dpi_c0d8039a2b3d4f69b31e6cbb1c611c5a_apkmirror.com.apkm",
+ "D:\\apkTest\\抖音V39.1.2谷歌版.apks"
+ );
+
+ private static final String PROP_KEY = "apk.test.files";
+ private static final String ENV_KEY = "APK_TEST_FILES";
+
+ private List resolveApkPaths() {
+ String raw = System.getProperty(PROP_KEY);
+ if (raw == null || raw.isBlank()) {
+ raw = System.getenv(ENV_KEY);
+ }
+ if (raw != null && !raw.isBlank()) {
+ return Arrays.stream(raw.split(";"))
+ .map(String::trim)
+ .filter(s -> !s.isBlank())
+ .toList();
+ }
+ return APK_FILE_LIST;
+ }
+
+ @Test
+ @DisplayName("批量解析本地 apk 文件并打印元数据")
+ void parseLocalApks() throws IOException {
+ List paths = resolveApkPaths();
+ if (paths.isEmpty()) {
+ System.out.println("===== 跳过测试 =====");
+ System.out.println("未配置任何待测试的 apk 文件,请通过以下方式之一提供:");
+ System.out.println(" 1. 在 ApkMetaParserTest.APK_FILE_LIST 中填入本地路径列表");
+ System.out.println(" 2. 系统属性 -Dapk.test.files=D:/a.apk;D:/b.apk");
+ System.out.println(" 3. 环境变量 APK_TEST_FILES=D:/a.apk;D:/b.apk");
+ return;
+ }
+
+ ApkMetaParser parser = new ApkMetaParser();
+ int total = paths.size();
+ int success = 0;
+
+ for (String path : paths) {
+ File apkFile = new File(path);
+ System.out.println();
+ System.out.println(">>>>>> [" + (success + 1) + "/" + total + "] " + apkFile.getAbsolutePath());
+
+ if (!apkFile.exists() || !apkFile.isFile()) {
+ System.out.println("!! 跳过:文件不存在或不是普通文件");
+ continue;
+ }
+
+ try {
+ System.out.println("文件魔数为: " + FileUtils.getMagicBytes(apkFile.getAbsolutePath(), 0, 4));
+ System.out.println("文件类型为: " + new AndroidPackageTypeDetector().detect(apkFile.toPath()));
+
+ ApkMetaParser.ApkMetaResult result = parser.parse(apkFile);
+ assertNotNull(result, "解析结果不应为空");
+ 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("maxSdk : " + result.getMaxSdk());
+ 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
+ ? "null" : result.getIconBytes().length + " bytes"));
+ System.out.println("=====================");
+ success++;
+ } catch (Exception e) {
+ System.out.println("!! 解析失败:" + e.getMessage());
+ }
+ }
+
+ System.out.println();
+ System.out.println("===== 批量测试完成:成功 " + success + " / 共 " + total + " 个 =====");
+ }
+}