feat(app): 实现安装包文件下载、优化AAB/APKS解析并完善异常处理
- 新增安装包文件流下载接口,支持通过ID下载原始文件并设置正确的文件名 - 重构AAB/APKS解析逻辑,直接使用bundletool依赖库替代命令行调用 - 为AAB/APKS添加图标提取功能,按密度优先策略选择最清晰图标 - 分离APKS与APKM/XAPK解析流程,前者使用bundletool解析,后者保留解包方案 - 添加NoResourceFoundException全局异常处理,返回404而非默认错误页
This commit is contained in:
@@ -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>AAB:ZIP 容器,内部为 base/manifest/AndroidManifest.xml 等模块结构,
|
||||
* 解包后用 apk-parser 解析二进制 Manifest(兜底:bundletool 命令行)</li>
|
||||
* <li>APKS/APKM/XAPK:ZIP 容器,内部包含一个或多个 APK 及描述文件,
|
||||
* 解包后定位主 APK,复用 APK 解析逻辑,再补充 JSON 描述信息</li>
|
||||
* <li>AAB:Android App Bundle,直接调用 Google bundletool 依赖(非命令行、非 jar)
|
||||
* 的 {@link DumpCommand} 输出 base 模块 AndroidManifest.xml 文本并提取关键字段</li>
|
||||
* <li>APKS:Google 官方拆分 APK 集,同样使用 bundletool 依赖解析整包 Manifest,
|
||||
* 并补充内部 split apk 列表</li>
|
||||
* <li>APKM/XAPK:第三方容器格式(非 Google 标准 bundle),bundletool 无法识别,
|
||||
* 解包后定位主 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 {
|
||||
/**
|
||||
* 解析 AAB(Android 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)。
|
||||
* 解析 APKS(Google 官方拆分 APK 集)。
|
||||
* <p>
|
||||
* 通用流程:
|
||||
* 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)。
|
||||
* <p>
|
||||
* 这两类为第三方容器(非 Google 标准 bundle),bundletool 无法识别,故沿用
|
||||
* 解包方案:
|
||||
* <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) > xxhdpi(3) > xhdpi(2) > hdpi(1) > 其它(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) {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user