feat(app): 实现安装包文件下载、优化AAB/APKS解析并完善异常处理

- 新增安装包文件流下载接口,支持通过ID下载原始文件并设置正确的文件名
- 重构AAB/APKS解析逻辑,直接使用bundletool依赖库替代命令行调用
- 为AAB/APKS添加图标提取功能,按密度优先策略选择最清晰图标
- 分离APKS与APKM/XAPK解析流程,前者使用bundletool解析,后者保留解包方案
- 添加NoResourceFoundException全局异常处理,返回404而非默认错误页
This commit is contained in:
2026-08-08 01:52:03 +08:00
parent 3422d546c5
commit e5e3f2c741
6 changed files with 305 additions and 56 deletions

View File

@@ -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 库
* <p>
* 支持五种格式:
* <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>
* <li>AABAndroid App Bundle直接调用 Google bundletool 依赖(非命令行、非 jar
* 的 {@link DumpCommand} 输出 base 模块 AndroidManifest.xml 文本并提取关键字段</li>
* <li>APKSGoogle 官方拆分 APK 集,同样使用 bundletool 依赖解析整包 Manifest
* 并补充内部 split apk 列表</li>
* <li>APKM/XAPK第三方容器格式非 Google 标准 bundlebundletool 无法识别,
* 解包后定位主 APK 复用 APK 解析逻辑,再补充 JSON 描述信息</li>
* </ul>
*/
@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 {
/**
* 解析 AABAndroid App Bundle
* <p>
* 策略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/<abi> 与图标资源(图标在运行时生成),此处跳过
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)。
* 解析 APKSGoogle 官方拆分 APK 集)。
* <p>
* 通用流程:
* 1. 用 zip4j / java.util.zip 解包,收集所有 .apk 及描述文件manifest.json / info.json
* 2. 定位主 APKbase-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
* <p>
* 这两类为第三方容器(非 Google 标准 bundlebundletool 无法识别,故沿用
* 解包方案:
* <ol>
* <li>解包收集所有 .apk 及描述文件manifest.json / info.json</li>
* <li>定位主 APK复用 APK 解析逻辑</li>
* <li>补充解析 JSON 描述文件</li>
* </ol>
*/
public ApkMetaResult parseContainer(File containerFile) throws IOException {
List<ContainerEntry> apks = new ArrayList<>();
@@ -247,6 +237,185 @@ public class ApkMetaParser {
return null;
}
// ============================ bundletool 库解析 ============================
/**
* 使用 bundletool 依赖(非命令行、非 jar的 {@link DumpCommand} 输出 Manifest 文本。
* <p>
* 通过 {@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<String> collectApkEntries(File zipFile) {
List<String> apkNames = new ArrayList<>();
try (ZipFile zf = new ZipFile(zipFile)) {
Enumeration<? extends ZipEntry> 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 中提取应用图标。
* <p>
* 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<String> prefixFilter) {
String bestName = null;
int bestRank = -1;
Enumeration<? extends ZipEntry> 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<String> 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) {
// 忽略
}
}
/**
* 为图标条目打分(越高越优先):
* <ul>
* <li>仅识别 res/mipmap-* 与 res/drawable-* 下的 png / webp</li>
* <li>mipmap 优先于 drawable+1000</li>
* <li>密度越高优先xxxhdpi(4) &gt; xxhdpi(3) &gt; xhdpi(2) &gt; hdpi(1) &gt; 其它(0)</li>
* <li>跳过 anydpi / nodpi / *_round 等不适合直接作为图标字节的条目</li>
* </ul>
*/
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) {}

View File

@@ -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();
}
}
}

View File

@@ -49,4 +49,20 @@ public interface AppPackageService {
* @param categories 分类编码列表
*/
void updateCategories(String id, List<String> 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);
}

View File

@@ -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);

View File

@@ -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 <T> Result<T> processException(NoResourceFoundException e) {
return Result.failed(ResultCode.INTERFACE_NOT_EXIST);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.OK)
public <T> Result<T> processException(MissingServletRequestParameterException e) {