From e5e3f2c741ee288733e677bfbe42b7e948908702 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Sat, 8 Aug 2026 01:52:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=AE=9E=E7=8E=B0=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=8C=85=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=E3=80=81?= =?UTF-8?q?=E4=BC=98=E5=8C=96AAB/APKS=E8=A7=A3=E6=9E=90=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增安装包文件流下载接口,支持通过ID下载原始文件并设置正确的文件名 - 重构AAB/APKS解析逻辑,直接使用bundletool依赖库替代命令行调用 - 为AAB/APKS添加图标提取功能,按密度优先策略选择最清晰图标 - 分离APKS与APKM/XAPK解析流程,前者使用bundletool解析,后者保留解包方案 - 添加NoResourceFoundException全局异常处理,返回404而非默认错误页 --- pom.xml | 1 - .../app/packages/component/ApkMetaParser.java | 279 ++++++++++++++---- .../controller/AppPackageController.java | 36 +++ .../packages/service/AppPackageService.java | 16 + .../service/impl/AppPackageServiceImpl.java | 22 ++ .../web/advice/GlobalExceptionHandler.java | 7 + 6 files changed, 305 insertions(+), 56 deletions(-) diff --git a/pom.xml b/pom.xml index 91d0a83c..98e9123b 100644 --- a/pom.xml +++ b/pom.xml @@ -146,7 +146,6 @@ com.android.tools.build bundletool 1.18.3 - runtime 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 2432973b..f72314f8 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,14 +1,17 @@ package com.youlai.boot.app.packages.component; +import com.android.tools.build.bundletool.commands.DumpCommand; import lombok.Data; import net.dongliu.apk.parser.ApkFile; import net.dongliu.apk.parser.bean.ApkMeta; import net.dongliu.apk.parser.bean.Icon; import org.springframework.stereotype.Component; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; @@ -17,19 +20,23 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.function.Predicate; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; /** - * Android 安装包元数据解析器(基于 apk-parser + zip4j) + * Android 安装包元数据解析器(基于 apk-parser + bundletool 库) *

* 支持五种格式: *

*/ @Component @@ -64,7 +71,8 @@ public class ApkMetaParser { return switch (ext) { case "apk" -> parseApk(packageFile); case "aab" -> parseAab(packageFile); - case "apks", "apkm", "xapk" -> parseContainer(packageFile); + case "apks" -> parseApks(packageFile); + case "apkm", "xapk" -> parseContainer(packageFile); default -> throw new IllegalArgumentException("不支持的安装包格式: ." + ext); }; } @@ -92,62 +100,44 @@ public class ApkMetaParser { /** * 解析 AAB(Android App Bundle)。 *

- * 策略:AAB 本质是一个 ZIP,解包后读取 base/manifest/AndroidManifest.xml 这个二进制 XML, - * 用 apk-parser 的 ApkFile 直接解析(2.6.x 兼容 AAB 内部文件)。 - * 若 apk-parser 解析失败,则尝试调用 bundletool 命令行 dump manifest 兜底。 + * 直接使用 bundletool 依赖(非命令行、非 jar)的 {@link DumpCommand} 输出 + * base 模块的 AndroidManifest.xml 文本,再提取关键字段。 */ 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); - } + ApkMetaResult result = parseByBundleTool(aabFile); + // 从 AAB 的 base 模块资源目录(res/mipmap-*、res/drawable-*)提取应用图标 + fillBundleIcon(result, aabFile, true); + return result; } - /** - * 兜底方案:调用 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 ============================ + // ============================ APKS ============================ /** - * 解析 ZIP 容器格式(APKS / APKM / XAPK)。 + * 解析 APKS(Google 官方拆分 APK 集)。 *

- * 通用流程: - * 1. 用 zip4j / java.util.zip 解包,收集所有 .apk 及描述文件(manifest.json / info.json) - * 2. 定位主 APK(base-master.apk / base.apk / 第一个能解析出 packageName 的) - * 3. 复用 APK 解析逻辑 - * 4. 补充解析 JSON 描述文件(obb / split 列表等) + * bundletool 依赖可直接解析 .apks 整包并 dump 其 Manifest;同时枚举 ZIP 内 + * 的 apk 条目记录 split 列表。 + */ + public ApkMetaResult parseApks(File apksFile) throws IOException { + ApkMetaResult result = parseByBundleTool(apksFile); + result.setSplitApkList(collectApkEntries(apksFile)); + // 图标位于内部各 split apk 的 res/mipmap-* 或 res/drawable-*,逐包扫描 + fillBundleIcon(result, apksFile, false); + return result; + } + + // ============================ APKM / XAPK ============================ + + /** + * 解析 ZIP 容器格式(APKM / XAPK)。 + *

+ * 这两类为第三方容器(非 Google 标准 bundle),bundletool 无法识别,故沿用 + * 解包方案: + *

    + *
  1. 解包收集所有 .apk 及描述文件(manifest.json / info.json)
  2. + *
  3. 定位主 APK,复用 APK 解析逻辑
  4. + *
  5. 补充解析 JSON 描述文件
  6. + *
*/ public ApkMetaResult parseContainer(File containerFile) throws IOException { List apks = new ArrayList<>(); @@ -247,6 +237,185 @@ public class ApkMetaParser { return null; } + // ============================ bundletool 库解析 ============================ + + /** + * 使用 bundletool 依赖(非命令行、非 jar)的 {@link DumpCommand} 输出 Manifest 文本。 + *

+ * 通过 {@link DumpCommand.Builder#setOutputStream(PrintStream)} 将 base 模块的 + * AndroidManifest.xml(文本形式)写入内存,再由 {@link #parseManifestXmlText} 提取字段。 + * 支持 .aab 与 .apks 两种 Google 标准 bundle。 + */ + private ApkMetaResult parseByBundleTool(File bundleFile) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (PrintStream ps = new PrintStream(baos, true, StandardCharsets.UTF_8)) { + DumpCommand.builder() + .setBundlePath(bundleFile.toPath()) + .setDumpTarget(DumpCommand.DumpTarget.MANIFEST) + .setOutputStream(ps) + .build() + .execute(); + } + String manifestXml = baos.toString(StandardCharsets.UTF_8); + return parseManifestXmlText(manifestXml); + } + + /** + * 枚举 zip 内所有 .apk 条目名(用于 APKS 记录 split 列表)。 + */ + private List collectApkEntries(File zipFile) { + List apkNames = new ArrayList<>(); + try (ZipFile zf = new ZipFile(zipFile)) { + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory() && entry.getName().toLowerCase(Locale.ROOT).endsWith(".apk")) { + apkNames.add(entry.getName()); + } + } + } catch (IOException ignored) { + // 忽略 + } + return apkNames; + } + + /** + * 从 AAB / APKS 中提取应用图标。 + *

+ * AAB:图标位于 base 模块的 res/mipmap-* 或 res/drawable-*;APKS:位于内部各 + * split apk 的 res/mipmap-* 或 res/drawable-*。策略是扫描资源目录中的 png / webp + * 图标,按「mipmap 优先 + 密度更高优先」选取最清晰的一张。 + * + * @param isAab true 表示 AAB(路径前缀 base/res/),false 表示 APKS(需解内嵌 apk) + */ + private void fillBundleIcon(ApkMetaResult result, File bundleFile, boolean isAab) { + if (result.getIconBytes() != null && result.getIconBytes().length > 0) { + return; + } + try (ZipFile zf = new ZipFile(bundleFile)) { + if (isAab) { + // AAB:直接扫描外层 zip 的 base/res/ 目录 + IconCandidate best = scanBestIcon(zf, n -> n.startsWith("base/res/")); + applyIcon(result, zf, best); + } else { + // APKS:遍历内部各 split apk,逐个扫描 res/ 目录 + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory() || !entry.getName().toLowerCase(Locale.ROOT).endsWith(".apk")) { + continue; + } + try (InputStream in = zf.getInputStream(entry); + ZipInputStream zis = new ZipInputStream(in)) { + IconCandidate best = scanBestIconFromStream(zis, n -> n.startsWith("res/")); + if (best != null) { + result.setIconPath(best.name); + result.setIconBytes(best.data); + return; + } + } + } + } + } catch (IOException ignored) { + // 图标解析失败不影响其它字段 + } + } + + /** + * 用 ZipFile 随机访问扫描:先记录最佳条目名,再按名读取字节。 + */ + private IconCandidate scanBestIcon(ZipFile zf, Predicate prefixFilter) { + String bestName = null; + int bestRank = -1; + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory() || !prefixFilter.test(entry.getName())) { + continue; + } + int rank = iconRank(entry.getName()); + if (rank > bestRank) { + bestRank = rank; + bestName = entry.getName(); + } + } + return bestName == null ? null : new IconCandidate(bestName, bestRank, null); + } + + /** + * 用 ZipInputStream 流式扫描(用于嵌套 apk),同时读入候选图标字节。 + */ + private IconCandidate scanBestIconFromStream(ZipInputStream zis, Predicate prefixFilter) { + IconCandidate best = null; + try { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory() || !prefixFilter.test(entry.getName())) { + continue; + } + int rank = iconRank(entry.getName()); + if (best == null || rank > best.rank) { + best = new IconCandidate(entry.getName(), rank, zis.readAllBytes()); + } + } + } catch (IOException ignored) { + // 忽略单个 split 的解析异常 + } + return best; + } + + private void applyIcon(ApkMetaResult result, ZipFile zf, IconCandidate candidate) { + if (candidate == null) { + return; + } + try (InputStream in = zf.getInputStream(zf.getEntry(candidate.name))) { + result.setIconPath(candidate.name); + byte[] data = in.readAllBytes(); + if (data != null && data.length > 0) { + result.setIconBytes(data); + } + } catch (IOException ignored) { + // 忽略 + } + } + + /** + * 为图标条目打分(越高越优先): + *

    + *
  • 仅识别 res/mipmap-* 与 res/drawable-* 下的 png / webp
  • + *
  • mipmap 优先于 drawable(+1000)
  • + *
  • 密度越高优先:xxxhdpi(4) > xxhdpi(3) > xhdpi(2) > hdpi(1) > 其它(0)
  • + *
  • 跳过 anydpi / nodpi / *_round 等不适合直接作为图标字节的条目
  • + *
+ */ + private int iconRank(String path) { + String lower = path.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".png") && !lower.endsWith(".webp")) { + return -1; + } + if (lower.contains("anydpi") || lower.contains("nodpi") || lower.contains("_round")) { + return -1; + } + boolean isMipmap = lower.contains("/res/mipmap-"); + boolean isDrawable = lower.contains("/res/drawable-"); + if (!isMipmap && !isDrawable) { + return -1; + } + int density = 0; + if (lower.contains("xxxhdpi")) { + density = 4; + } else if (lower.contains("xxhdpi")) { + density = 3; + } else if (lower.contains("xhdpi")) { + density = 2; + } else if (lower.contains("hdpi")) { + density = 1; + } + return (isMipmap ? 1000 : 0) + density; + } + + private record IconCandidate(String name, int rank, byte[] data) {} + // ============================ 通用工具 ============================ private record ContainerEntry(String name, byte[] data) {} diff --git a/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java index 12cb78df..45b25eb4 100644 --- a/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java +++ b/src/main/java/com/youlai/boot/app/packages/controller/AppPackageController.java @@ -10,10 +10,20 @@ import com.youlai.boot.common.result.Result; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; /** @@ -85,4 +95,30 @@ public class AppPackageController { appPackageService.updateCategories(id, categories); return Result.success(true); } + + @Operation(summary = "下载安装包文件流(文件不存在返回404,不触发错误下载)") + @GetMapping("/{id}/download") + public void download(@Parameter(description = "安装包ID") @PathVariable String id, HttpServletResponse response) throws IOException { + File file = appPackageService.getPackageFile(id); + if (file == null) { + response.sendError(HttpStatus.NOT_FOUND.value(), "安装包文件不存在"); + return; + } + String originalName = appPackageService.getOriginalName(id); + String encodedName = URLEncoder.encode(originalName, StandardCharsets.UTF_8).replace("+", "%20"); + response.reset(); + response.setContentType("application/octet-stream"); + response.setContentLengthLong(file.length()); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + encodedName + "\"; filename*=UTF-8''" + encodedName); + try (OutputStream out = response.getOutputStream(); + BufferedInputStream in = new BufferedInputStream(new FileInputStream(file))) { + byte[] buffer = new byte[8192]; + int len; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + out.flush(); + } + } } diff --git a/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java index b9e899d8..098685db 100644 --- a/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java +++ b/src/main/java/com/youlai/boot/app/packages/service/AppPackageService.java @@ -49,4 +49,20 @@ public interface AppPackageService { * @param categories 分类编码列表 */ void updateCategories(String id, List categories); + + /** + * 下载安装包文件流。文件不存在时返回 null,由 Controller 返回 404。 + * + * @param id 安装包ID + * @return 安装包文件,不存在返回 null + */ + java.io.File getPackageFile(String id); + + /** + * 获取安装包原始文件名(用于下载时的 Content-Disposition 文件名)。 + * + * @param id 安装包ID + * @return 原始文件名,不存在返回 null + */ + String getOriginalName(String id); } 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 84d59d35..19c1f9cc 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 @@ -234,6 +234,28 @@ public class AppPackageServiceImpl implements AppPackageService { appPackageRepository.updateCategories(id, categories); } + @Override + public File getPackageFile(String id) { + AppPackageDocument doc = appPackageRepository.findById(id); + if (doc == null || doc.getFileName() == null) { + return null; + } + File file = new File(FilePath.getPackagePath() + doc.getFileName()); + if (!file.exists() || !file.isFile()) { + return null; + } + return file; + } + + @Override + public String getOriginalName(String id) { + AppPackageDocument doc = appPackageRepository.findById(id); + if (doc == null || doc.getOriginalName() == null) { + return "package"; + } + return doc.getOriginalName(); + } + private AppPackageVO toVO(AppPackageDocument doc) { AppPackageVO vo = new AppPackageVO(); BeanUtils.copyProperties(doc, vo); diff --git a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java index ca9e524b..937ea2bb 100644 --- a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java +++ b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java @@ -28,6 +28,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import tools.jackson.core.JacksonException; import java.sql.SQLIntegrityConstraintViolationException; @@ -77,6 +78,12 @@ public class GlobalExceptionHandler { return Result.failed(ResultCode.INTERFACE_NOT_EXIST); } + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Result processException(NoResourceFoundException e) { + return Result.failed(ResultCode.INTERFACE_NOT_EXIST); + } + @ExceptionHandler(MissingServletRequestParameterException.class) @ResponseStatus(HttpStatus.OK) public Result processException(MissingServletRequestParameterException e) {