feat: 添加 jbsdiff 依赖并配置 shade 打包为可执行工具
This commit is contained in:
35
dependency-reduced-pom.xml
Normal file
35
dependency-reduced-pom.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.ttstd</groupId>
|
||||
<artifactId>MavenUtils</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer>
|
||||
<mainClass>com.ttstd.BsDiffTool</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
</properties>
|
||||
</project>
|
||||
33
pom.xml
33
pom.xml
@@ -13,6 +13,13 @@
|
||||
<artifactId>lunar</artifactId>
|
||||
<version>1.7.7</version>
|
||||
</dependency>
|
||||
<!-- Java 版 bsdiff/bspatch(malensek/jbsdiff,BSD 协议),
|
||||
传递依赖 org.apache.commons:commons-compress -->
|
||||
<dependency>
|
||||
<groupId>io.sigpipe</groupId>
|
||||
<artifactId>jbsdiff</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<properties>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
@@ -20,4 +27,30 @@
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- 打包为可直接 java -jar 运行的命令行工具 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.ttstd.BsDiffTool</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
521
src/main/java/com/ttstd/ApkDeltaUpdater.java
Normal file
521
src/main/java/com/ttstd/ApkDeltaUpdater.java
Normal file
@@ -0,0 +1,521 @@
|
||||
package com.ttstd;
|
||||
|
||||
import io.sigpipe.jbsdiff.InvalidHeaderException;
|
||||
import io.sigpipe.jbsdiff.ui.FileUI;
|
||||
import org.apache.commons.compress.compressors.CompressorException;
|
||||
import org.apache.commons.compress.compressors.CompressorStreamFactory;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* APK 省流增量更新:按文件大小分档,自动选择差量引擎与执行模式。
|
||||
*
|
||||
* <h3>一、工具 Android 实现评估(先评估、再用依赖)</h3>
|
||||
* <table border="1">
|
||||
* <tr><th>工具</th><th>Android 端形态</th><th>官方 Maven/AAR</th><th>结论</th></tr>
|
||||
* <tr><td>HDiffPatch</td><td>C++,需 NDK 交叉编译 + 自行 JNI 封装</td><td>无</td>
|
||||
* <td>{@link Engine#HDIFFPATCH}:仅运行时探测,本工程未内置</td></tr>
|
||||
* <tr><td>xdelta3</td><td>C,需 NDK 交叉编译 + JNI,生态仅零散移植</td><td>无</td>
|
||||
* <td>{@link Engine#XDELTA3}:同上</td></tr>
|
||||
* <tr><td>bsdiff</td><td>C,有 Android 移植(Tinker / AOSP),非 Maven 包</td><td>无</td>
|
||||
* <td>由纯 Java 实现 jbsdiff 替代</td></tr>
|
||||
* <tr><td>jbsdiff</td><td>纯 Java,无 native 依赖,Android 可直接运行</td><td>有</td>
|
||||
* <td>{@link Engine#BSDIFF}:默认执行引擎</td></tr>
|
||||
* <tr><td>UpdateEngine / imgdiff</td><td>{@code @SystemApi},系统签名才能调用</td><td>无</td>
|
||||
* <td>普通 App 不可用,排除</td></tr>
|
||||
* </table>
|
||||
* <p>结论:HDiffPatch / xdelta3 无现成 Android 依赖,接入需自建 NDK/JNI;本工程为纯 Java
|
||||
* Maven 工具,执行层使用 {@code io.sigpipe:jbsdiff}。分档仍按各档理论最优引擎规划,
|
||||
* native 引擎可用时可平滑替换。</p>
|
||||
*
|
||||
* <h3>二、分档策略(以新包大小为准)</h3>
|
||||
* <table border="1">
|
||||
* <tr><th>档位</th><th>大小</th><th>首选引擎</th><th>模式</th><th>压缩</th>
|
||||
* <th>单条目上限</th></tr>
|
||||
* <tr><td>SMALL</td><td>< 64MB</td><td>HDiffPatch</td><td>整包</td><td>bzip2</td><td>-</td></tr>
|
||||
* <tr><td>MEDIUM</td><td>64~128MB</td><td>xdelta3</td><td>整包</td><td>gzip</td><td>-</td></tr>
|
||||
* <tr><td>LARGE</td><td>128~256MB</td><td>bsdiff</td><td>条目级</td><td>bzip2</td><td>64MB</td></tr>
|
||||
* <tr><td>XLARGE</td><td>256~512MB</td><td>bsdiff</td><td>条目级</td><td>bzip2</td><td>32MB</td></tr>
|
||||
* <tr><td>HUGE</td><td>> 512MB</td><td>bsdiff</td><td>条目级</td><td>bzip2</td><td>16MB</td></tr>
|
||||
* </table>
|
||||
* <p>128MB 起改条目级:整包 diff 峰值约 3~5 倍文件大小,会超过服务器 1G 可用内存;
|
||||
* 条目级把单次 diff 对象降为单条目。补丁率超过 40% 时建议回退全量下发。</p>
|
||||
*
|
||||
* <h3>三、条目级 patch 容器格式</h3>
|
||||
* <p>patch 本身是 zip,含 {@code delta-data/NNNNNN.patch|raw} 与清单
|
||||
* {@code META-INF/delta-manifest},清单每行一条操作(tab 分隔):</p>
|
||||
* <pre>
|
||||
* COPY <name> old 同名同 CRC,原样拷贝
|
||||
* ADD <name> <dataEntry> old 无同名或超上限,直接携带
|
||||
* DIFF <name> <dataEntry> old 条目 + 容器内 bsdiff 补丁还原
|
||||
* </pre>
|
||||
*/
|
||||
public final class ApkDeltaUpdater {
|
||||
|
||||
private static final long MB = 1024L * 1024L;
|
||||
|
||||
/** 条目级 patch 容器中清单条目的名称。 */
|
||||
private static final String MANIFEST_ENTRY = "META-INF/delta-manifest";
|
||||
|
||||
/** 补丁率超过该值即失去省流意义,应回退全量下发。 */
|
||||
public static final double FULL_PACKAGE_THRESHOLD = 0.40;
|
||||
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private ApkDeltaUpdater() {
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 一、差量引擎与可用性探测
|
||||
// ==========================================================
|
||||
|
||||
/** 差量引擎。native 引擎需自行 NDK 编译与 JNI 绑定,本工程仅内置 jbsdiff。 */
|
||||
public enum Engine {
|
||||
/** HDiffPatch:补丁最小,native。 */
|
||||
HDIFFPATCH("HDiffPatch", "hdiffpatch"),
|
||||
/** xdelta3:流式、内存可控,native。 */
|
||||
XDELTA3("xdelta3", "xdelta3"),
|
||||
/** bsdiff:纯 Java(jbsdiff),默认执行引擎。 */
|
||||
BSDIFF("bsdiff(jbsdiff)", null);
|
||||
|
||||
private final String displayName;
|
||||
private final String nativeLibrary;
|
||||
|
||||
Engine(String displayName, String nativeLibrary) {
|
||||
this.displayName = displayName;
|
||||
this.nativeLibrary = nativeLibrary;
|
||||
}
|
||||
|
||||
public String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/** 是否为 native 引擎(需 NDK 编译与 JNI 绑定)。 */
|
||||
public boolean isNative() {
|
||||
return nativeLibrary != null;
|
||||
}
|
||||
|
||||
/** 本工程是否内置该引擎的执行实现(目前仅 jbsdiff)。 */
|
||||
public boolean hasExecutor() {
|
||||
return this == BSDIFF;
|
||||
}
|
||||
|
||||
/** 运行时探测:native 引擎尝试加载动态库,失败即视为不可用。 */
|
||||
public boolean isAvailable() {
|
||||
if (!isNative()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
System.loadLibrary(nativeLibrary);
|
||||
return true;
|
||||
} catch (UnsatisfiedLinkError | SecurityException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 打印各引擎的 Android 实现形态与当前可用性。 */
|
||||
public static void printEngineEvaluation() {
|
||||
System.out.println("[差量引擎评估]");
|
||||
for (Engine engine : Engine.values()) {
|
||||
String form = engine.isNative()
|
||||
? "需 NDK 交叉编译 + JNI(无官方 Maven/AAR)"
|
||||
: "纯 Java(io.sigpipe:jbsdiff)";
|
||||
System.out.printf(" %-18s %-44s 可用=%s 内置执行=%s%n",
|
||||
engine.displayName(), form, engine.isAvailable(), engine.hasExecutor());
|
||||
}
|
||||
System.out.println(" 结论: HDiffPatch/xdelta3 无现成 Android 依赖,执行层回退 bsdiff(jbsdiff)");
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 二、按文件大小分档
|
||||
// ==========================================================
|
||||
|
||||
/** 档位(上界不含):SMALL < 64MB < MEDIUM < 128MB < LARGE < 256MB
|
||||
* < XLARGE < 512MB < HUGE。 */
|
||||
public enum SizeTier {
|
||||
SMALL(64 * MB),
|
||||
MEDIUM(128 * MB),
|
||||
LARGE(256 * MB),
|
||||
XLARGE(512 * MB),
|
||||
HUGE(Long.MAX_VALUE);
|
||||
|
||||
private final long upperBoundExclusive;
|
||||
|
||||
SizeTier(long upperBoundExclusive) {
|
||||
this.upperBoundExclusive = upperBoundExclusive;
|
||||
}
|
||||
|
||||
public long upperBoundExclusive() {
|
||||
return upperBoundExclusive;
|
||||
}
|
||||
|
||||
/** 根据文件大小(字节)归档。 */
|
||||
public static SizeTier of(long fileSize) {
|
||||
for (SizeTier tier : values()) {
|
||||
if (fileSize < tier.upperBoundExclusive) {
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
return HUGE;
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行模式。 */
|
||||
public enum Mode {
|
||||
/** 整包 bsdiff。 */
|
||||
WHOLE_FILE,
|
||||
/** zip 条目级 bsdiff(压低单次 diff 内存峰值)。 */
|
||||
ENTRY_LEVEL,
|
||||
/** 不差量,直接携带全量。 */
|
||||
FULL_PACKAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* 差量执行计划,由 {@link DeltaPlan#plan(long)} 按新包大小生成。
|
||||
*
|
||||
* @param tier 命中的档位
|
||||
* @param preferredEngine 该档理论最优引擎(不可用时回退)
|
||||
* @param mode 执行模式
|
||||
* @param compressor bsdiff 压缩算法(commons-compress 名称)
|
||||
* @param maxDiffEntrySize 条目级下单条目 diff 上限(字节),整包模式为 0
|
||||
*/
|
||||
public record DeltaPlan(SizeTier tier, Engine preferredEngine, Mode mode,
|
||||
String compressor, long maxDiffEntrySize) {
|
||||
|
||||
/** 根据新包大小生成差量计划(分档策略的唯一入口)。 */
|
||||
public static DeltaPlan plan(long newFileSize) {
|
||||
SizeTier tier = SizeTier.of(newFileSize);
|
||||
return switch (tier) {
|
||||
case SMALL -> new DeltaPlan(tier, Engine.HDIFFPATCH, Mode.WHOLE_FILE,
|
||||
CompressorStreamFactory.BZIP2, 0);
|
||||
case MEDIUM -> new DeltaPlan(tier, Engine.XDELTA3, Mode.WHOLE_FILE,
|
||||
CompressorStreamFactory.GZIP, 0);
|
||||
case LARGE -> new DeltaPlan(tier, Engine.BSDIFF, Mode.ENTRY_LEVEL,
|
||||
CompressorStreamFactory.BZIP2, 64 * MB);
|
||||
case XLARGE -> new DeltaPlan(tier, Engine.BSDIFF, Mode.ENTRY_LEVEL,
|
||||
CompressorStreamFactory.BZIP2, 32 * MB);
|
||||
case HUGE -> new DeltaPlan(tier, Engine.BSDIFF, Mode.ENTRY_LEVEL,
|
||||
CompressorStreamFactory.BZIP2, 16 * MB);
|
||||
};
|
||||
}
|
||||
|
||||
/** 实际可执行引擎:首选引擎无内置实现或不可用时回退 jbsdiff。 */
|
||||
public Engine resolveEngine() {
|
||||
if (preferredEngine.hasExecutor() && preferredEngine.isAvailable()) {
|
||||
return preferredEngine;
|
||||
}
|
||||
return Engine.BSDIFF;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 三、生成 / 应用
|
||||
// ==========================================================
|
||||
|
||||
/** 按新包大小自动分档,生成 patch。 */
|
||||
public static void createPatch(File oldFile, File newFile, File patchFile)
|
||||
throws IOException, CompressorException, InvalidHeaderException {
|
||||
createPatch(oldFile, newFile, patchFile, DeltaPlan.plan(newFile.length()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定计划生成 patch。生成后计算补丁率,超过
|
||||
* {@link #FULL_PACKAGE_THRESHOLD} 时输出回退全量的警告。
|
||||
*/
|
||||
public static void createPatch(File oldFile, File newFile, File patchFile, DeltaPlan plan)
|
||||
throws IOException, CompressorException, InvalidHeaderException {
|
||||
long startNanos = System.nanoTime();
|
||||
Engine engine = plan.resolveEngine();
|
||||
if (engine != plan.preferredEngine()) {
|
||||
System.out.printf("[createPatch] 首选引擎 %s 不可用(native 未内置),回退 %s%n",
|
||||
plan.preferredEngine().displayName(), engine.displayName());
|
||||
}
|
||||
switch (plan.mode()) {
|
||||
case WHOLE_FILE -> FileUI.diff(oldFile, newFile, patchFile, plan.compressor());
|
||||
case ENTRY_LEVEL -> createEntryLevelPatch(oldFile, newFile, patchFile, plan);
|
||||
case FULL_PACKAGE -> Files.copy(newFile.toPath(), patchFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
double ratio = patchRatio(newFile, patchFile);
|
||||
System.out.printf(
|
||||
"[createPatch] tier=%s engine=%s mode=%s old=%d new=%d patch=%d ratio=%.1f%% 耗时 %s%n",
|
||||
plan.tier(), engine.displayName(), plan.mode(), oldFile.length(), newFile.length(),
|
||||
patchFile.length(), ratio * 100, formatDuration(System.nanoTime() - startNanos));
|
||||
if (ratio > FULL_PACKAGE_THRESHOLD) {
|
||||
System.out.printf("[createPatch][warn] 补丁率超过 %.0f%%,建议回退全量下发%n",
|
||||
FULL_PACKAGE_THRESHOLD * 100);
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用 patch 还原新包,自动识别整包 bsdiff 与条目级容器两种格式。 */
|
||||
public static void applyPatch(File oldFile, File patchFile, File newFile)
|
||||
throws IOException, CompressorException, InvalidHeaderException {
|
||||
long startNanos = System.nanoTime();
|
||||
boolean entryLevel = isEntryLevelPatch(patchFile);
|
||||
if (entryLevel) {
|
||||
applyEntryLevelPatch(oldFile, patchFile, newFile);
|
||||
} else {
|
||||
FileUI.patch(oldFile, newFile, patchFile);
|
||||
}
|
||||
System.out.printf("[applyPatch] %s old=%d patch=%d new=%d 耗时 %s%n",
|
||||
entryLevel ? "entry-level" : "whole-file",
|
||||
oldFile.length(), patchFile.length(), newFile.length(),
|
||||
formatDuration(System.nanoTime() - startNanos));
|
||||
}
|
||||
|
||||
/** 补丁率:patch 大小 / 新包大小。 */
|
||||
public static double patchRatio(File newFile, File patchFile) {
|
||||
long newSize = Math.max(1L, newFile.length());
|
||||
return (double) patchFile.length() / newSize;
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 四、条目级实现
|
||||
// ==========================================================
|
||||
|
||||
/** 条目级 diff:未变化条目记 COPY,超过上限的改动条目直接携带(ADD)。 */
|
||||
private static void createEntryLevelPatch(File oldFile, File newFile, File patchFile,
|
||||
DeltaPlan plan)
|
||||
throws IOException, CompressorException, InvalidHeaderException {
|
||||
Map<String, long[]> oldDigests = scanDigests(oldFile);
|
||||
try (ZipFile oldZip = new ZipFile(oldFile);
|
||||
ZipFile newZip = new ZipFile(newFile);
|
||||
ZipOutputStream patchOut = new ZipOutputStream(
|
||||
new BufferedOutputStream(new FileOutputStream(patchFile)))) {
|
||||
|
||||
List<String> manifest = new ArrayList<>();
|
||||
int dataIndex = 0;
|
||||
var entries = newZip.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
if (entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
String name = entry.getName();
|
||||
long[] newDigest = new long[2];
|
||||
File newEntryFile = extractAndDigest(newZip, name, newDigest);
|
||||
try {
|
||||
long[] oldDigest = oldDigests.get(name);
|
||||
if (oldDigest != null
|
||||
&& oldDigest[0] == newDigest[0]
|
||||
&& oldDigest[1] == newDigest[1]) {
|
||||
manifest.add("COPY\t" + name);
|
||||
continue;
|
||||
}
|
||||
boolean canDiff = oldDigest != null
|
||||
&& newDigest[0] <= plan.maxDiffEntrySize()
|
||||
&& oldDigest[0] <= plan.maxDiffEntrySize();
|
||||
if (canDiff) {
|
||||
File oldEntryFile = extractEntry(oldZip, name);
|
||||
File entryPatch = Files.createTempFile("apkdelta-entry-", ".patch").toFile();
|
||||
try {
|
||||
FileUI.diff(oldEntryFile, newEntryFile, entryPatch, plan.compressor());
|
||||
String dataEntry = "delta-data/%06d.patch".formatted(dataIndex++);
|
||||
writeFileAsEntry(entryPatch, dataEntry, patchOut);
|
||||
manifest.add("DIFF\t" + name + "\t" + dataEntry);
|
||||
} finally {
|
||||
deleteQuietly(oldEntryFile);
|
||||
deleteQuietly(entryPatch);
|
||||
}
|
||||
} else {
|
||||
String dataEntry = "delta-data/%06d.raw".formatted(dataIndex++);
|
||||
writeFileAsEntry(newEntryFile, dataEntry, patchOut);
|
||||
manifest.add("ADD\t" + name + "\t" + dataEntry);
|
||||
}
|
||||
} finally {
|
||||
deleteQuietly(newEntryFile);
|
||||
}
|
||||
}
|
||||
patchOut.putNextEntry(new ZipEntry(MANIFEST_ENTRY));
|
||||
patchOut.write(String.join("\n", manifest).getBytes(StandardCharsets.UTF_8));
|
||||
patchOut.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
/** 条目级 patch 应用:按清单逐条 COPY / ADD / DIFF 重建新 zip。 */
|
||||
private static void applyEntryLevelPatch(File oldFile, File patchFile, File newFile)
|
||||
throws IOException, CompressorException, InvalidHeaderException {
|
||||
try (ZipFile oldZip = new ZipFile(oldFile);
|
||||
ZipFile patchZip = new ZipFile(patchFile);
|
||||
ZipOutputStream newOut = new ZipOutputStream(
|
||||
new BufferedOutputStream(new FileOutputStream(newFile)))) {
|
||||
String manifest;
|
||||
try (InputStream in = patchZip.getInputStream(patchZip.getEntry(MANIFEST_ENTRY))) {
|
||||
manifest = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
for (String line : manifest.split("\n")) {
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = line.split("\t");
|
||||
String op = parts[0];
|
||||
String name = parts[1];
|
||||
switch (op) {
|
||||
case "COPY" -> copyZipEntry(oldZip, name, newOut, name);
|
||||
case "ADD" -> copyZipEntry(patchZip, parts[2], newOut, name);
|
||||
case "DIFF" -> {
|
||||
File oldEntryFile = extractEntry(oldZip, name);
|
||||
File entryPatch = extractEntry(patchZip, parts[2]);
|
||||
File restored = Files.createTempFile("apkdelta-restored-", ".entry").toFile();
|
||||
try {
|
||||
FileUI.patch(oldEntryFile, restored, entryPatch);
|
||||
writeFileAsEntry(restored, name, newOut);
|
||||
} finally {
|
||||
deleteQuietly(oldEntryFile);
|
||||
deleteQuietly(entryPatch);
|
||||
deleteQuietly(restored);
|
||||
}
|
||||
}
|
||||
default -> throw new IOException("未知清单操作: " + op);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// 五、工具方法
|
||||
// ==========================================================
|
||||
|
||||
/** patch 是否为条目级容器(zip 且含清单条目)。 */
|
||||
private static boolean isEntryLevelPatch(File patchFile) {
|
||||
try (ZipFile zip = new ZipFile(patchFile)) {
|
||||
return zip.getEntry(MANIFEST_ENTRY) != null;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描 zip:name -> [size, crc32]。 */
|
||||
private static Map<String, long[]> scanDigests(File zip) throws IOException {
|
||||
Map<String, long[]> map = new LinkedHashMap<>();
|
||||
try (ZipFile zf = new ZipFile(zip)) {
|
||||
var it = zf.entries();
|
||||
while (it.hasMoreElements()) {
|
||||
ZipEntry e = it.nextElement();
|
||||
if (e.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
map.put(e.getName(), new long[]{e.getSize(), crcOf(zf, e)});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static long crcOf(ZipFile zip, ZipEntry entry) throws IOException {
|
||||
CRC32 crc = new CRC32();
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
try (InputStream in = zip.getInputStream(entry)) {
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
crc.update(buf, 0, n);
|
||||
}
|
||||
}
|
||||
return crc.getValue();
|
||||
}
|
||||
|
||||
/** 导出条目到临时文件,同时回填 [size, crc32]。 */
|
||||
private static File extractAndDigest(ZipFile zip, String name, long[] outSizeCrc)
|
||||
throws IOException {
|
||||
ZipEntry e = zip.getEntry(name);
|
||||
if (e == null) {
|
||||
throw new IOException("zip 条目不存在: " + name + " in " + zip.getName());
|
||||
}
|
||||
File tmp = Files.createTempFile("apkdelta-", ".entry").toFile();
|
||||
CRC32 crc = new CRC32();
|
||||
long total = 0;
|
||||
byte[] buf = new byte[BUFFER_SIZE];
|
||||
try (InputStream in = zip.getInputStream(e);
|
||||
OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp))) {
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, n);
|
||||
crc.update(buf, 0, n);
|
||||
total += n;
|
||||
}
|
||||
}
|
||||
outSizeCrc[0] = total;
|
||||
outSizeCrc[1] = crc.getValue();
|
||||
return tmp;
|
||||
}
|
||||
|
||||
private static File extractEntry(ZipFile zip, String name) throws IOException {
|
||||
ZipEntry e = zip.getEntry(name);
|
||||
if (e == null) {
|
||||
throw new IOException("zip 条目不存在: " + name + " in " + zip.getName());
|
||||
}
|
||||
File tmp = Files.createTempFile("apkdelta-", ".entry").toFile();
|
||||
try (InputStream in = zip.getInputStream(e);
|
||||
OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp))) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/** 将源 zip 条目(可改名)写入输出 zip。 */
|
||||
private static void copyZipEntry(ZipFile src, String srcName, ZipOutputStream out,
|
||||
String destName) throws IOException {
|
||||
ZipEntry e = src.getEntry(srcName);
|
||||
if (e == null) {
|
||||
throw new IOException("zip 条目不存在: " + srcName + " in " + src.getName());
|
||||
}
|
||||
try (InputStream in = src.getInputStream(e)) {
|
||||
out.putNextEntry(new ZipEntry(destName));
|
||||
in.transferTo(out);
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeFileAsEntry(File file, String entryName, ZipOutputStream out)
|
||||
throws IOException {
|
||||
try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
|
||||
out.putNextEntry(new ZipEntry(entryName));
|
||||
in.transferTo(out);
|
||||
out.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteQuietly(File file) {
|
||||
if (file != null) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/** 时长格式化:ms / s / min / h。 */
|
||||
private static String formatDuration(long nanos) {
|
||||
double ms = nanos / 1_000_000.0;
|
||||
if (ms < 1_000.0) {
|
||||
return String.format(Locale.ROOT, "%.3f ms", ms);
|
||||
}
|
||||
double totalSeconds = ms / 1_000.0;
|
||||
if (totalSeconds < 60.0) {
|
||||
return String.format(Locale.ROOT, "%.3f s", totalSeconds);
|
||||
}
|
||||
long minutes = (long) (totalSeconds / 60.0);
|
||||
if (minutes < 60) {
|
||||
return String.format(Locale.ROOT, "%d min %.3f s", minutes, totalSeconds - minutes * 60.0);
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
return String.format(Locale.ROOT, "%d h %d min %.3f s",
|
||||
hours, minutes % 60, totalSeconds - minutes * 60.0);
|
||||
}
|
||||
}
|
||||
172
src/main/java/com/ttstd/BsDiffTool.java
Normal file
172
src/main/java/com/ttstd/BsDiffTool.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.ttstd;
|
||||
|
||||
import io.sigpipe.jbsdiff.InvalidHeaderException;
|
||||
import io.sigpipe.jbsdiff.ui.FileUI;
|
||||
import org.apache.commons.compress.compressors.CompressorException;
|
||||
import org.apache.commons.compress.compressors.CompressorStreamFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 基于 jbsdiff(io.sigpipe:jbsdiff)的命令行 bsdiff / bspatch 工具。
|
||||
*
|
||||
* <p>用法:</p>
|
||||
* <pre>
|
||||
* diff <oldFile> <newFile> <patchFile> [-c|--compressor bzip2|gz|pack200|xz]
|
||||
* patch <oldFile> <patchFile> <newFile>
|
||||
* </pre>
|
||||
*
|
||||
* <p>示例:</p>
|
||||
* <pre>
|
||||
* java -jar MavenUtils.jar diff old.bin new.bin patch.bin
|
||||
* java -jar MavenUtils.jar patch old.bin patch.bin restored.bin
|
||||
* </pre>
|
||||
*
|
||||
* <p>每次 bsdiff / bspatch 执行完成后都会输出耗时。</p>
|
||||
*/
|
||||
public class BsDiffTool {
|
||||
|
||||
private static final String DEFAULT_COMPRESSOR = CompressorStreamFactory.BZIP2;
|
||||
|
||||
private BsDiffTool() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length < 1) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
String command = args[0].toLowerCase(Locale.ROOT);
|
||||
try {
|
||||
switch (command) {
|
||||
case "bsdiff" -> diff(args);
|
||||
case "bspatch" -> patch(args);
|
||||
default -> printUsage();
|
||||
}
|
||||
} catch (CompressorException | InvalidHeaderException | IOException e) {
|
||||
System.err.println("Error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* bsdiff:由 oldFile 与 newFile 生成 patchFile。
|
||||
*/
|
||||
private static void diff(String[] args)
|
||||
throws CompressorException, InvalidHeaderException, IOException {
|
||||
if (args.length < 4) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
File oldFile = new File(args[1]);
|
||||
File newFile = new File(args[2]);
|
||||
File patchFile = new File(args[3]);
|
||||
String compression = parseCompressor(args);
|
||||
|
||||
requireExists(oldFile, "oldFile");
|
||||
requireExists(newFile, "newFile");
|
||||
|
||||
long startNanos = System.nanoTime();
|
||||
FileUI.diff(oldFile, newFile, patchFile, compression);
|
||||
|
||||
System.out.printf(
|
||||
"[bsdiff] %s (%d bytes) -> %s (%d bytes), compressor=%s%n",
|
||||
oldFile, oldFile.length(), newFile, newFile.length(), compression);
|
||||
System.out.printf("[bsdiff] patch 文件已生成: %s (%d bytes)%n",
|
||||
patchFile, patchFile.length());
|
||||
System.out.printf("[bsdiff] bsdiff 完成,耗时 %s%n",
|
||||
formatDuration(System.nanoTime() - startNanos));
|
||||
}
|
||||
|
||||
/**
|
||||
* bspatch:由 oldFile 与 patchFile 还原出 newFile(压缩算法自动识别)。
|
||||
*/
|
||||
private static void patch(String[] args)
|
||||
throws CompressorException, InvalidHeaderException, IOException {
|
||||
if (args.length < 4) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
File oldFile = new File(args[1]);
|
||||
File patchFile = new File(args[2]);
|
||||
File newFile = new File(args[3]);
|
||||
|
||||
requireExists(oldFile, "oldFile");
|
||||
requireExists(patchFile, "patchFile");
|
||||
|
||||
long startNanos = System.nanoTime();
|
||||
FileUI.patch(oldFile, newFile, patchFile);
|
||||
|
||||
System.out.printf(
|
||||
"[bspatch] %s (%d bytes) + %s (%d bytes) -> %s (%d bytes)%n",
|
||||
oldFile, oldFile.length(), patchFile, patchFile.length(),
|
||||
newFile, newFile.length());
|
||||
System.out.printf("[bspatch] bspatch 完成,耗时 %s%n",
|
||||
formatDuration(System.nanoTime() - startNanos));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参数中解析压缩算法,支持 "-c xxx" / "--compressor xxx",默认 bzip2。
|
||||
*/
|
||||
private static String parseCompressor(String[] args) {
|
||||
for (int i = 4; i < args.length - 1; i++) {
|
||||
if ("-c".equals(args[i]) || "--compressor".equals(args[i])) {
|
||||
String name = args[i + 1].toLowerCase(Locale.ROOT);
|
||||
// CompressorStreamFactory 中 gzip 的别名为 "gz"
|
||||
return "gzip".equals(name) ? CompressorStreamFactory.GZIP : name;
|
||||
}
|
||||
}
|
||||
return DEFAULT_COMPRESSOR;
|
||||
}
|
||||
|
||||
private static void requireExists(File file, String name) {
|
||||
if (!file.isFile()) {
|
||||
System.err.printf("Error: %s 不存在或不是文件: %s%n", name, file);
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时长(纳秒)转换为可读字符串:
|
||||
* 不足 1 秒用 ms,不足 1 分钟用 s,不足 1 小时用 min,否则用 h。
|
||||
*/
|
||||
private static String formatDuration(long nanos) {
|
||||
double ms = nanos / 1_000_000.0;
|
||||
if (ms < 1_000.0) {
|
||||
return String.format(Locale.ROOT, "%.3f ms", ms);
|
||||
}
|
||||
double totalSeconds = ms / 1_000.0;
|
||||
if (totalSeconds < 60.0) {
|
||||
return String.format(Locale.ROOT, "%.3f s", totalSeconds);
|
||||
}
|
||||
long minutes = (long) (totalSeconds / 60.0);
|
||||
if (minutes < 60) {
|
||||
return String.format(Locale.ROOT, "%d min %.3f s", minutes, totalSeconds - minutes * 60.0);
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
return String.format(Locale.ROOT, "%d h %d min %.3f s",
|
||||
hours, minutes % 60, totalSeconds - minutes * 60.0);
|
||||
}
|
||||
|
||||
private static void printUsage() {
|
||||
System.out.println("""
|
||||
用法:
|
||||
diff <oldFile> <newFile> <patchFile> [-c|--compressor bzip2|gz|pack200|xz]
|
||||
patch <oldFile> <patchFile> <newFile>
|
||||
|
||||
示例:
|
||||
java -jar MavenUtils.jar diff old.bin new.bin patch.bin
|
||||
java -jar MavenUtils.jar patch old.bin patch.bin restored.bin
|
||||
|
||||
说明:
|
||||
diff 默认使用 bzip2 压缩(与 bsdiff 参考实现兼容),
|
||||
可用 --compressor 指定 gz / pack200 / xz;
|
||||
patch 会自动识别补丁所用的压缩算法。""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user