diff --git a/pom.xml b/pom.xml index fcd31de6..91d0a83c 100644 --- a/pom.xml +++ b/pom.xml @@ -135,6 +135,26 @@ 2.6.10 + + + net.lingala.zip4j + zip4j + 2.11.5 + + + + com.android.tools.build + bundletool + 1.18.3 + runtime + + + + com.android.tools.apkparser + binary-resources + 31.2.0 + + org.springframework.boot spring-boot-starter-cache diff --git a/src/main/java/com/youlai/boot/app/packages/component/AndroidPackageTypeDetector.java b/src/main/java/com/youlai/boot/app/packages/component/AndroidPackageTypeDetector.java new file mode 100644 index 00000000..3651dbf1 --- /dev/null +++ b/src/main/java/com/youlai/boot/app/packages/component/AndroidPackageTypeDetector.java @@ -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 entries = new HashSet<>(); + try (ZipFile zf = new ZipFile(file.toFile())) { + Enumeration 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; + } +} \ No newline at end of file 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 7f10730f..2432973b 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 @@ -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) *

- * 用于从 apk 文件中尽可能详细地读取包名、版本、SDK 版本、权限、CPU 架构、图标等信息。 - * aab / xapk / apks / apkm 为其他格式,本解析器不适用,由上传流程按文件基础信息处理。 + * 支持五种格式: + *

*/ @Component public class ApkMetaParser { @@ -38,54 +49,374 @@ public class ApkMetaParser { private List permissions = new ArrayList<>(); private String iconPath; private byte[] iconBytes; + /** 容器格式(apks/apkm/xapk)内解析出的 split apk 列表,便于审计 */ + private List 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// 目录 + fillFromApkMeta(result, meta); result.setAbiList(extractAbiList(apkFile)); - - // 图标:优先 launcher 图标,取首个图标的字节 - 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) { - // 图标解析失败不影响其它字段 - } + 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 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 entries = zf.entries(); + Enumeration 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 + " 个 ====="); + } +}