* 支持五种格式: *
- * 策略: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/
- * 通用流程:
- * 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 无法识别,故沿用
+ * 解包方案:
+ *
+ * 通过 {@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
+ * 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 extends ZipEntry> 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
+ *
*/
public ApkMetaResult parseContainer(File containerFile) throws IOException {
List
+ *
+ */
+ 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