diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml index 9ccd54a..4a9199e 100644 --- a/dependency-reduced-pom.xml +++ b/dependency-reduced-pom.xml @@ -18,7 +18,7 @@ - com.ttstd.BsDiffTool + com.ttstd.ApkDeltaUpdater diff --git a/pom.xml b/pom.xml index 36ef263..a581dbf 100644 --- a/pom.xml +++ b/pom.xml @@ -13,13 +13,6 @@ lunar 1.7.7 - - - io.sigpipe - jbsdiff - 1.0 - 25 @@ -43,7 +36,7 @@ - com.ttstd.BsDiffTool + com.ttstd.ApkDeltaUpdater diff --git a/src/main/java/com/ttstd/ApkDeltaUpdater.java b/src/main/java/com/ttstd/ApkDeltaUpdater.java index fe47072..b9a7941 100644 --- a/src/main/java/com/ttstd/ApkDeltaUpdater.java +++ b/src/main/java/com/ttstd/ApkDeltaUpdater.java @@ -1,149 +1,292 @@ 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.BufferedReader; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; -import java.nio.charset.StandardCharsets; +import java.nio.charset.Charset; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.Arrays; 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; +import java.util.zip.ZipInputStream; /** - * APK 省流增量更新:按文件大小分档,自动选择差量引擎与执行模式。 + * 基于 HDiffPatch(hdiffz / hpatchz)的 APK 增量更新工具。 + * + *

一、执行方式

+ *

只使用 HDiffPatch:diff 调用 {@code hdiffz},patch 调用 {@code hpatchz}。 + * 官方 Releases v5.1.3 的常用平台二进制压缩包内置在 jar 的 + * {@code /hdiffpatch/} 目录下,运行时按 {@code os.name} / {@code os.arch} + * 选择对应平台,解压到 {@code $TMP/ttstd-hdiffpatch/5.1.3-/}, + * Unix 平台自动补执行位后以子进程方式调用。

* - *

一、工具 Android 实现评估(先评估、再用依赖)

* - * - * - * - * - * - * - * - * - * - * - * + * + * + * + * + * *
工具Android 端形态官方 Maven/AAR结论
HDiffPatchC++,需 NDK 交叉编译 + 自行 JNI 封装无{@link Engine#HDIFFPATCH}:仅运行时探测,本工程未内置
xdelta3C,需 NDK 交叉编译 + JNI,生态仅零散移植无{@link Engine#XDELTA3}:同上
bsdiffC,有 Android 移植(Tinker / AOSP),非 Maven 包无由纯 Java 实现 jbsdiff 替代
jbsdiff纯 Java,无 native 依赖,Android 可直接运行有{@link Engine#BSDIFF}:默认执行引擎
UpdateEngine / imgdiff{@code @SystemApi},系统签名才能调用无普通 App 不可用,排除
os.nameos.arch内置目录
Windowsamd64 / x86_64windows64
Linuxamd64 / x86_64linux64
Linuxaarch64 / arm64linux_arm64
macOS任意(通用二进制)macos
- *

结论:HDiffPatch / xdelta3 无现成 Android 依赖,接入需自建 NDK/JNI;本工程为纯 Java - * Maven 工具,执行层使用 {@code io.sigpipe:jbsdiff}。分档仍按各档理论最优引擎规划, - * native 引擎可用时可平滑替换。

+ *

仅内置以上平台;windows32、linux32、linux_arm32、linux_loong64、 + * linux_riscv32、linux_riscv64 等平台运行时将提示缺少内置资源,需用 + * {@code -Dttstd.hdiffpatch.dir=<目录>} 指定外部二进制(目录内需有当前平台的 + * {@code hdiffz}/{@code hpatchz},Windows 为 .exe)。

* - *

二、分档策略(以新包大小为准)

+ *

二、分档参数(以 old/new 中较大者为准)

+ *

hdiffz 帮助给出的内存模型:{@code -m} 需 + * {@code newSize + oldSize*5};{@code -s-B} 只需 + * {@code oldSize*16/B}(B 为 matchBlockSize,单位字节);{@code -SD} + * 把补丁压成单流,打补丁只需一个解压缓冲,并支持分步下载与多线程还原。

* - * - * - * - * - * - * - * + * + * + * + * + * + * + * + * + * + * + * *
档位大小首选引擎模式压缩单条目上限
SMALL< 64MBHDiffPatch整包bzip2-
MEDIUM64~128MBxdelta3整包gzip-
LARGE128~256MBbsdiff条目级bzip264MB
XLARGE256~512MBbsdiff条目级bzip232MB
HUGE> 512MBbsdiff条目级bzip216MB
档位包大小hdiffz 参数hpatchz 参数取舍
SMALL< 64MB{@code -s-32 -c-lzma2-9 -p-2}{@code -s-8m}流式而非 {@code -m}:最坏内存 359MB → 104MB, + * 补丁仅大 0.0025%
MEDIUM64~128MB{@code -s-64 -c-lzma2-9 -p-2}{@code -s-16m}{@code -m} 峰值可达 768MB,改为流式
LARGE128~256MB{@code -s-64 -SD-256k -c-lzma2-9 -p-2}{@code -s-32m}加 {@code -SD},降低客户端还原内存
XLARGE256~512MB{@code -s-128 -SD-512k -c-lzma2-9 -p-2}{@code -s-64m -p-2}放大匹配块压内存,线程降到 2
HUGE≥ 512MB{@code -s-256 -SD-1m -c-lzma2-9 -p-2}{@code -s-128m -p-2}匹配块再放大,内存进一步收敛
- *

128MB 起改条目级:整包 diff 峰值约 3~5 倍文件大小,会超过服务器 1G 可用内存; - * 条目级把单次 diff 对象降为单条目。补丁率超过 40% 时建议回退全量下发。

+ *

实测峰值(Windows x64 / hdiffz 5.1.3,仅 hdiffz 子进程,相似度约 97%): + * 60MB 45MB、100MB 71MB、200MB 98MB、320MB 101MB、560MB 104MB; + * 新旧包几乎不相似时各档上界为 104MB、104MB、104MB、104MB、104MB。 + * SMALL 不再使用 {@code -m}(最坏约 new+old×5,60MB 达 359MB),改为 + * {@code -s-32}:APK 风格语料上补丁仅大 0.0025%,最坏峰值降到 104MB。 + * 大档不使用 zstd-19 + p4:同一 293MB 测试下该组合峰值 552MB,1GB 服务器余量不足。

+ *

分档取 {@code max(oldSize, newSize)}:流式内存由旧包匹配表决定, + * 新包很小但旧包很大时也需要按旧包选档。

+ *

补丁率超过 {@link #FULL_PACKAGE_THRESHOLD} 时提示回退全量下发。

* - *

三、条目级 patch 容器格式

- *

patch 本身是 zip,含 {@code delta-data/NNNNNN.patch|raw} 与清单 - * {@code META-INF/delta-manifest},清单每行一条操作(tab 分隔):

+ *

三、APK 处理方式

+ *

APK 按整包二进制做 diff:补丁由 old 与 new 逐字节还原,因此还原产物与 + * 新包完全一致,签名和 zipalign 对齐都不会被破坏。生成方与使用方需使用兼容 + * 的 hpatchz 版本({@code -SD} 需要 v5.0+,本工程内置 v5.1.3)。

+ * + *

四、命令行

*
- *   COPY  <name>                     old 同名同 CRC,原样拷贝
- *   ADD   <name>  <dataEntry>      old 无同名或超上限,直接携带
- *   DIFF  <name>  <dataEntry>      old 条目 + 容器内 bsdiff 补丁还原
+ *   java -jar MavenUtils.jar diff  <oldFile> <newFile> <patchFile> [选项]
+ *   java -jar MavenUtils.jar patch <oldFile> <patchFile> <newFile> [选项]
+ *   java -jar MavenUtils.jar info  <patchFile>
+ *   java -jar MavenUtils.jar env
  * 
*/ public final class ApkDeltaUpdater { - private static final long MB = 1024L * 1024L; - - /** 条目级 patch 容器中清单条目的名称。 */ - private static final String MANIFEST_ENTRY = "META-INF/delta-manifest"; + /** 内置二进制对应的 HDiffPatch 版本。 */ + public static final String HDIFFPATCH_VERSION = "5.1.3"; /** 补丁率超过该值即失去省流意义,应回退全量下发。 */ public static final double FULL_PACKAGE_THRESHOLD = 0.40; - private static final int BUFFER_SIZE = 64 * 1024; + /** 覆盖内置二进制的系统属性:值为包含 hdiffz/hpatchz 的目录。 */ + public static final String BIN_DIR_PROPERTY = "ttstd.hdiffpatch.dir"; + + private static final long MB = 1024L * 1024L; + private static final String RESOURCE_DIR = "/hdiffpatch/"; + private static final String CACHE_DIR = "ttstd-hdiffpatch"; + + private static Platform detectedPlatform; 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); + /** 受支持的平台,folder 对应官方 Releases 压缩包内的目录名。 */ + public enum Platform { + WINDOWS_X64("windows64", true), + WINDOWS_X86("windows32", true), + WINDOWS_ARM64("windows_arm64", true), + LINUX_X64("linux64", false), + LINUX_X86("linux32", false), + LINUX_ARM64("linux_arm64", false), + LINUX_ARM32("linux_arm32", false), + LINUX_LOONG64("linux_loong64", false), + LINUX_RISCV64("linux_riscv64", false), + LINUX_RISCV32("linux_riscv32", false), + MACOS("macos", false); - private final String displayName; - private final String nativeLibrary; + private final String folder; + private final boolean windows; - Engine(String displayName, String nativeLibrary) { - this.displayName = displayName; - this.nativeLibrary = nativeLibrary; + Platform(String folder, boolean windows) { + this.folder = folder; + this.windows = windows; } - public String displayName() { - return displayName; + public String folder() { + return folder; } - /** 是否为 native 引擎(需 NDK 编译与 JNI 绑定)。 */ - public boolean isNative() { - return nativeLibrary != null; + public boolean isWindows() { + return windows; } - /** 本工程是否内置该引擎的执行实现(目前仅 jbsdiff)。 */ - public boolean hasExecutor() { - return this == BSDIFF; + public String hdiffzName() { + return windows ? "hdiffz.exe" : "hdiffz"; } - /** 运行时探测:native 引擎尝试加载动态库,失败即视为不可用。 */ - public boolean isAvailable() { - if (!isNative()) { - return true; - } - try { - System.loadLibrary(nativeLibrary); - return true; - } catch (UnsatisfiedLinkError | SecurityException e) { - return false; - } + public String hpatchzName() { + return windows ? "hpatchz.exe" : "hpatchz"; + } + + String resourceName() { + return RESOURCE_DIR + "hdiffpatch_v" + HDIFFPATCH_VERSION + "_bin_" + folder + ".zip"; } } - /** 打印各引擎的 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()); + /** 按 os.name / os.arch 探测当前平台(结果缓存)。 */ + public static synchronized Platform currentPlatform() { + if (detectedPlatform == null) { + detectedPlatform = detectPlatform(); + } + return detectedPlatform; + } + + public static Platform detectPlatform() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + + if (os.contains("win")) { + if (arch.contains("aarch64") || arch.contains("arm64")) { + return Platform.WINDOWS_ARM64; + } + return is64Bit(arch) ? Platform.WINDOWS_X64 : Platform.WINDOWS_X86; + } + if (os.contains("mac") || os.contains("darwin")) { + return Platform.MACOS; + } + if (os.contains("linux")) { + if (arch.contains("aarch64") || arch.contains("arm64")) { + return Platform.LINUX_ARM64; + } + if (arch.startsWith("arm")) { + return Platform.LINUX_ARM32; + } + if (arch.contains("loong")) { + return Platform.LINUX_LOONG64; + } + if (arch.contains("riscv64")) { + return Platform.LINUX_RISCV64; + } + if (arch.contains("riscv")) { + return Platform.LINUX_RISCV32; + } + return is64Bit(arch) ? Platform.LINUX_X64 : Platform.LINUX_X86; + } + throw new IllegalStateException("不支持的平台: os.name=" + os + ", os.arch=" + arch + + ";可用 -D" + BIN_DIR_PROPERTY + "=<含 hdiffz/hpatchz 的目录> 指定"); + } + + private static boolean is64Bit(String arch) { + return arch.contains("64") || arch.contains("aarch64"); + } + + /** 当前平台的 hdiffz / hpatchz 所在目录(优先系统属性覆盖,否则解压内置资源)。 */ + public static Path binaryDir() throws IOException { + Platform platform = currentPlatform(); + String override = System.getProperty(BIN_DIR_PROPERTY); + Path dir; + if (override != null && !override.isBlank()) { + dir = Path.of(override).toAbsolutePath().normalize(); + if (!Files.isDirectory(dir)) { + throw new IOException(BIN_DIR_PROPERTY + " 不是目录: " + dir); + } + } else { + dir = extractBinaries(platform); + } + Path hdiffz = dir.resolve(platform.hdiffzName()); + Path hpatchz = dir.resolve(platform.hpatchzName()); + if (!Files.isRegularFile(hdiffz) || !Files.isRegularFile(hpatchz)) { + throw new IOException("目录中缺少 " + platform.hdiffzName() + " / " + + platform.hpatchzName() + ": " + dir); + } + return dir; + } + + private static Path extractBinaries(Platform platform) throws IOException { + Path dir = Path.of(System.getProperty("java.io.tmpdir"), CACHE_DIR, + HDIFFPATCH_VERSION + "-" + platform.folder()); + Path hdiffz = dir.resolve(platform.hdiffzName()); + Path hpatchz = dir.resolve(platform.hpatchzName()); + if (usable(hdiffz, platform) && usable(hpatchz, platform)) { + return dir; + } + + try (InputStream raw = ApkDeltaUpdater.class.getResourceAsStream(platform.resourceName())) { + if (raw == null) { + throw new IOException("jar 内缺少平台二进制: " + platform.resourceName() + + ";可用 -D" + BIN_DIR_PROPERTY + "=<目录> 指定"); + } + Files.createDirectories(dir); + boolean gotDiff = false; + boolean gotPatch = false; + try (ZipInputStream zip = new ZipInputStream(new BufferedInputStream(raw))) { + for (ZipEntry entry; (entry = zip.getNextEntry()) != null; ) { + if (entry.isDirectory()) { + continue; + } + String name = Path.of(entry.getName()).getFileName().toString(); + if (!name.equals(platform.hdiffzName()) && !name.equals(platform.hpatchzName())) { + continue; + } + Path tmp = Files.createTempFile(dir, name, ".tmp"); + try (OutputStream out = Files.newOutputStream(tmp)) { + zip.transferTo(out); + } + makeExecutable(tmp); + Path target = dir.resolve(name); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + // 并发解压时另一进程可能已放好可执行文件 + if (!usable(target, platform)) { + throw e; + } + } finally { + Files.deleteIfExists(tmp); + } + gotDiff |= name.equals(platform.hdiffzName()); + gotPatch |= name.equals(platform.hpatchzName()); + } + } + if (!gotDiff || !gotPatch) { + throw new IOException("压缩包缺少 hdiffz/hpatchz: " + platform.resourceName()); + } + } + return dir; + } + + private static boolean usable(Path exe, Platform platform) { + try { + return Files.isRegularFile(exe) && Files.size(exe) > 0 + && (platform.isWindows() || Files.isExecutable(exe)); + } catch (IOException e) { + return false; + } + } + + private static void makeExecutable(Path file) { + try { + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rwxr-xr-x")); + } catch (UnsupportedOperationException | IOException e) { + file.toFile().setExecutable(true, false); } - System.out.println(" 结论: HDiffPatch/xdelta3 无现成 Android 依赖,执行层回退 bsdiff(jbsdiff)"); } // ========================================================== @@ -153,22 +296,47 @@ public final class ApkDeltaUpdater { /** 档位(上界不含):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); + SMALL(64 * MB, List.of("-s-32", "-c-lzma2-9", "-p-2"), List.of("-s-8m")), + MEDIUM(128 * MB, List.of("-s-64", "-c-lzma2-9", "-p-2"), List.of("-s-16m")), + LARGE(256 * MB, List.of("-s-64", "-SD-256k", "-c-lzma2-9", "-p-2"), List.of("-s-32m")), + XLARGE(512 * MB, List.of("-s-128", "-SD-512k", "-c-lzma2-9", "-p-2"), List.of("-s-64m", "-p-2")), + HUGE(Long.MAX_VALUE, List.of("-s-256", "-SD-1m", "-c-lzma2-9", "-p-2"), List.of("-s-128m", "-p-2")); private final long upperBoundExclusive; + private final List diffArgs; + private final List patchArgs; - SizeTier(long upperBoundExclusive) { + SizeTier(long upperBoundExclusive, List diffArgs, List patchArgs) { this.upperBoundExclusive = upperBoundExclusive; + this.diffArgs = diffArgs; + this.patchArgs = patchArgs; } public long upperBoundExclusive() { return upperBoundExclusive; } + /** 档位对应的 hdiffz 参数(不含输入输出文件)。 */ + public List diffArgs() { + return diffArgs; + } + + /** 档位对应的 hpatchz 参数(不含输入输出文件)。 */ + public List patchArgs() { + return patchArgs; + } + + /** 人类可读的大小区间。 */ + public String range() { + return switch (this) { + case SMALL -> "< 64MB"; + case MEDIUM -> "64~128MB"; + case LARGE -> "128~256MB"; + case XLARGE -> "256~512MB"; + case HUGE -> ">= 512MB"; + }; + } + /** 根据文件大小(字节)归档。 */ public static SizeTier of(long fileSize) { for (SizeTier tier : values()) { @@ -180,51 +348,40 @@ public final class ApkDeltaUpdater { } } - /** 执行模式。 */ - public enum Mode { - /** 整包 bsdiff。 */ - WHOLE_FILE, - /** zip 条目级 bsdiff(压低单次 diff 内存峰值)。 */ - ENTRY_LEVEL, - /** 不差量,直接携带全量。 */ - FULL_PACKAGE - } - /** - * 差量执行计划,由 {@link DeltaPlan#plan(long)} 按新包大小生成。 + * 一次 diff / patch 的参数计划。 * - * @param tier 命中的档位 - * @param preferredEngine 该档理论最优引擎(不可用时回退) - * @param mode 执行模式 - * @param compressor bsdiff 压缩算法(commons-compress 名称) - * @param maxDiffEntrySize 条目级下单条目 diff 上限(字节),整包模式为 0 + * @param tier 命中的档位 + * @param diffArgs hdiffz 参数 + * @param patchArgs hpatchz 参数 */ - public record DeltaPlan(SizeTier tier, Engine preferredEngine, Mode mode, - String compressor, long maxDiffEntrySize) { + public record DeltaPlan(SizeTier tier, List diffArgs, List patchArgs) { - /** 根据新包大小生成差量计划(分档策略的唯一入口)。 */ - 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); - }; + public DeltaPlan { + diffArgs = List.copyOf(diffArgs); + patchArgs = List.copyOf(patchArgs); } - /** 实际可执行引擎:首选引擎无内置实现或不可用时回退 jbsdiff。 */ - public Engine resolveEngine() { - if (preferredEngine.hasExecutor() && preferredEngine.isAvailable()) { - return preferredEngine; - } - return Engine.BSDIFF; + /** 按 old/new 中较大者生成计划:流式内存由旧包匹配表决定。 */ + public static DeltaPlan forFiles(File oldFile, File newFile) { + return of(SizeTier.of(Math.max(oldFile.length(), newFile.length()))); + } + + /** 按旧包大小生成计划(应用补丁用,旧包与新包大小通常接近)。 */ + public static DeltaPlan forOldFile(File oldFile) { + return of(SizeTier.of(oldFile.length())); + } + + public static DeltaPlan of(SizeTier tier) { + return new DeltaPlan(tier, tier.diffArgs(), tier.patchArgs()); + } + + public DeltaPlan withDiffArgs(List args) { + return new DeltaPlan(tier, args, patchArgs); + } + + public DeltaPlan withPatchArgs(List args) { + return new DeltaPlan(tier, diffArgs, args); } } @@ -232,10 +389,10 @@ public final class ApkDeltaUpdater { // 三、生成 / 应用 // ========================================================== - /** 按新包大小自动分档,生成 patch。 */ + /** 按 old/new 中较大者自动分档,生成 patch。 */ public static void createPatch(File oldFile, File newFile, File patchFile) - throws IOException, CompressorException, InvalidHeaderException { - createPatch(oldFile, newFile, patchFile, DeltaPlan.plan(newFile.length())); + throws IOException, InterruptedException { + createPatch(oldFile, newFile, patchFile, DeltaPlan.forFiles(oldFile, newFile)); } /** @@ -243,23 +400,25 @@ public final class ApkDeltaUpdater { * {@link #FULL_PACKAGE_THRESHOLD} 时输出回退全量的警告。 */ public static void createPatch(File oldFile, File newFile, File patchFile, DeltaPlan plan) - throws IOException, CompressorException, InvalidHeaderException { + throws IOException, InterruptedException { + requireFile(oldFile, "oldFile"); + requireFile(newFile, "newFile"); 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); - } + + List args = new ArrayList<>(plan.diffArgs()); + args.add("-f"); + args.add(oldFile.getPath()); + args.add(newFile.getPath()); + args.add(patchFile.getPath()); + + ProcessResult result = exec(hdiffzPath(), args); + result.requireSuccess("hdiffz"); + System.out.print(result.output()); + 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(), + "[createPatch] tier=%s diff=%s old=%d new=%d patch=%d ratio=%.1f%% 耗时 %s%n", + plan.tier(), String.join(" ", plan.diffArgs()), oldFile.length(), newFile.length(), patchFile.length(), ratio * 100, formatDuration(System.nanoTime() - startNanos)); if (ratio > FULL_PACKAGE_THRESHOLD) { System.out.printf("[createPatch][warn] 补丁率超过 %.0f%%,建议回退全量下发%n", @@ -267,239 +426,309 @@ public final class ApkDeltaUpdater { } } - /** 应用 patch 还原新包,自动识别整包 bsdiff 与条目级容器两种格式。 */ + /** 应用 patch 还原新包(hpatchz 自动识别补丁格式与压缩算法)。 */ public static void applyPatch(File oldFile, File patchFile, File newFile) - throws IOException, CompressorException, InvalidHeaderException { + throws IOException, InterruptedException { + applyPatch(oldFile, patchFile, newFile, DeltaPlan.forOldFile(oldFile)); + } + + public static void applyPatch(File oldFile, File patchFile, File newFile, DeltaPlan plan) + throws IOException, InterruptedException { + requireFile(oldFile, "oldFile"); + requireFile(patchFile, "patchFile"); 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(), + + List args = new ArrayList<>(plan.patchArgs()); + args.add("-f"); + args.add(oldFile.getPath()); + args.add(patchFile.getPath()); + args.add(newFile.getPath()); + + ProcessResult result = exec(hpatchzPath(), args); + result.requireSuccess("hpatchz"); + System.out.print(result.output()); + + System.out.printf("[applyPatch] tier=%s patch=%s old=%d patch=%d new=%d 耗时 %s%n", + plan.tier(), String.join(" ", plan.patchArgs()), oldFile.length(), + patchFile.length(), newFile.length(), formatDuration(System.nanoTime() - startNanos)); } + /** 打印补丁信息(hdiffz -info)。 */ + public static String patchInfo(File patchFile) throws IOException, InterruptedException { + requireFile(patchFile, "patchFile"); + ProcessResult result = exec(hdiffzPath(), List.of("-info", patchFile.getPath())); + result.requireSuccess("hdiffz -info"); + return result.output(); + } + /** 补丁率:patch 大小 / 新包大小。 */ public static double patchRatio(File newFile, File patchFile) { long newSize = Math.max(1L, newFile.length()); return (double) patchFile.length() / newSize; } + private static Path hdiffzPath() throws IOException { + return binaryDir().resolve(currentPlatform().hdiffzName()); + } + + private static Path hpatchzPath() throws IOException { + return binaryDir().resolve(currentPlatform().hpatchzName()); + } + // ========================================================== - // 四、条目级实现 + // 四、进程调用 // ========================================================== - /** 条目级 diff:未变化条目记 COPY,超过上限的改动条目直接携带(ADD)。 */ - private static void createEntryLevelPatch(File oldFile, File newFile, File patchFile, - DeltaPlan plan) - throws IOException, CompressorException, InvalidHeaderException { - Map oldDigests = scanDigests(oldFile); - try (ZipFile oldZip = new ZipFile(oldFile); - ZipFile newZip = new ZipFile(newFile); - ZipOutputStream patchOut = new ZipOutputStream( - new BufferedOutputStream(new FileOutputStream(patchFile)))) { + private record ProcessResult(int exitCode, String output) { - List 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); - } + void requireSuccess(String tool) throws IOException { + if (exitCode == 0) { + return; } - patchOut.putNextEntry(new ZipEntry(MANIFEST_ENTRY)); - patchOut.write(String.join("\n", manifest).getBytes(StandardCharsets.UTF_8)); - patchOut.closeEntry(); + String text = output.stripTrailing(); + if (text.length() > 4000) { + text = "..." + text.substring(text.length() - 4000); + } + throw new IOException(tool + " 执行失败 (exit=" + exitCode + ")\n" + text); } } - /** 条目级 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); - } + private static ProcessResult exec(Path exe, List args) + throws IOException, InterruptedException { + List command = new ArrayList<>(args.size() + 1); + command.add(exe.toString()); + command.addAll(args); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + + StringBuilder output = new StringBuilder(); + Charset charset = Charset.forName(System.getProperty("native.encoding", "UTF-8")); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), charset))) { + for (String line; (line = reader.readLine()) != null; ) { + output.append(line).append(System.lineSeparator()); } } + return new ProcessResult(process.waitFor(), output.toString()); + } + + private static void requireFile(File file, String name) throws IOException { + if (file == null || !file.isFile()) { + throw new IOException(name + " 不存在或不是文件: " + file); + } } // ========================================================== - // 五、工具方法 + // 五、命令行 // ========================================================== - /** 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; + public static void main(String[] args) { + if (args.length == 0) { + printUsage(); + return; + } + try { + switch (args[0].toLowerCase(Locale.ROOT)) { + case "diff", "hdiff" -> cliDiff(args); + case "patch", "hpatch" -> cliPatch(args); + case "info" -> cliInfo(args); + case "env", "platform" -> cliEnv(); + default -> printUsage(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + System.err.println("Error: 执行被中断"); + System.exit(1); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + System.exit(1); } } - /** 扫描 zip:name -> [size, crc32]。 */ - private static Map scanDigests(File zip) throws IOException { - Map map = new LinkedHashMap<>(); - try (ZipFile zf = new ZipFile(zip)) { - var it = zf.entries(); - while (it.hasMoreElements()) { - ZipEntry e = it.nextElement(); - if (e.isDirectory()) { - continue; + private static void cliDiff(String[] args) throws IOException, InterruptedException { + if (args.length < 4) { + printUsage(); + return; + } + File oldFile = new File(args[1]); + File newFile = new File(args[2]); + File patchFile = new File(args[3]); + + int passthroughIndex = indexOfPassthrough(args, 4); + int optionEnd = passthroughIndex < 0 ? args.length : passthroughIndex; + + SizeTier tier = null; + String compress = null; + Integer threads = null; + for (int i = 4; i < optionEnd; i++) { + switch (args[i]) { + case "-t", "--tier" -> { + requireValue(args, i, optionEnd); + tier = parseTier(args[++i]); } - map.put(e.getName(), new long[]{e.getSize(), crcOf(zf, e)}); + case "-c", "--compress" -> { + requireValue(args, i, optionEnd); + compress = args[++i]; + } + case "-p", "--threads" -> { + requireValue(args, i, optionEnd); + threads = parseThreads(args[++i]); + } + default -> throw new IllegalArgumentException("未知参数: " + args[i]); } } - return map; + + SizeTier effectiveTier = tier != null + ? tier + : SizeTier.of(Math.max(oldFile.length(), newFile.length())); + List extraArgs = passthroughIndex < 0 + ? List.of() + : List.of(Arrays.copyOfRange(args, passthroughIndex + 1, args.length)); + List diffArgs = overrideDiffArgs( + DeltaPlan.of(effectiveTier).diffArgs(), compress, threads, extraArgs); + + createPatch(oldFile, newFile, patchFile, + DeltaPlan.of(effectiveTier).withDiffArgs(diffArgs)); } - 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); + private static void cliPatch(String[] args) throws IOException, InterruptedException { + if (args.length < 4) { + printUsage(); + return; + } + File oldFile = new File(args[1]); + File patchFile = new File(args[2]); + File newFile = new File(args[3]); + + int passthroughIndex = indexOfPassthrough(args, 4); + int optionEnd = passthroughIndex < 0 ? args.length : passthroughIndex; + + String cache = null; + for (int i = 4; i < optionEnd; i++) { + switch (args[i]) { + case "-c", "--cache" -> { + requireValue(args, i, optionEnd); + cache = args[++i]; + } + default -> throw new IllegalArgumentException("未知参数: " + args[i]); } } - return crc.getValue(); + + List extraArgs = passthroughIndex < 0 + ? List.of() + : List.of(Arrays.copyOfRange(args, passthroughIndex + 1, args.length)); + DeltaPlan plan = DeltaPlan.forOldFile(oldFile); + List patchArgs = overridePatchArgs(plan.patchArgs(), cache, extraArgs); + + applyPatch(oldFile, patchFile, newFile, plan.withPatchArgs(patchArgs)); } - /** 导出条目到临时文件,同时回填 [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()); + private static void cliInfo(String[] args) throws IOException, InterruptedException { + if (args.length < 2) { + printUsage(); + return; } - 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; + System.out.print(patchInfo(new File(args[1]))); + } + + private static void cliEnv() throws IOException { + Platform platform = currentPlatform(); + Path dir = binaryDir(); + System.out.println("[环境]"); + System.out.printf(" os.name = %s%n", System.getProperty("os.name")); + System.out.printf(" os.arch = %s%n", System.getProperty("os.arch")); + System.out.printf(" 平台 = %s (%s)%n", platform, platform.folder()); + System.out.printf(" 版本 = HDiffPatch v%s%n", HDIFFPATCH_VERSION); + System.out.printf(" hdiffz = %s%n", dir.resolve(platform.hdiffzName())); + System.out.printf(" hpatchz = %s%n", dir.resolve(platform.hpatchzName())); + System.out.println(); + System.out.println("[档位参数]"); + for (SizeTier tier : SizeTier.values()) { + System.out.printf(" %-6s %-11s diff: %-48s patch: %s%n", + tier, tier.range(), String.join(" ", tier.diffArgs()), + String.join(" ", tier.patchArgs())); + } + } + + private static int indexOfPassthrough(String[] args, int from) { + for (int i = from; i < args.length; i++) { + if ("--".equals(args[i])) { + return i; } } - outSizeCrc[0] = total; - outSizeCrc[1] = crc.getValue(); - return tmp; + return -1; } - 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 requireValue(String[] args, int index, int end) { + if (index + 1 >= end) { + throw new IllegalArgumentException(args[index] + " 缺少参数值"); } } - 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 SizeTier parseTier(String value) { + try { + return SizeTier.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "未知档位: " + value + "(small|medium|large|xlarge|huge)"); } } - private static void deleteQuietly(File file) { - if (file != null) { - file.delete(); + private static int parseThreads(String value) { + try { + int threads = Integer.parseInt(value); + if (threads < 1) { + throw new NumberFormatException(); + } + return threads; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("线程数必须 >= 1: " + value); } } + private static List overrideDiffArgs(List base, String compress, + Integer threads, List extra) { + List result = new ArrayList<>(); + for (String arg : base) { + if (compress != null && arg.startsWith("-c-")) { + continue; + } + if (threads != null && arg.matches("-p-\\d+")) { + continue; + } + result.add(arg); + } + if (compress != null) { + result.add(normalizePrefixed(compress, "-c-")); + } + if (threads != null) { + result.add("-p-" + threads); + } + result.addAll(extra); + return result; + } + + private static List overridePatchArgs(List base, String cache, + List extra) { + List result = new ArrayList<>(); + for (String arg : base) { + if (cache != null && arg.startsWith("-s-")) { + continue; + } + result.add(arg); + } + if (cache != null) { + result.add(normalizePrefixed(cache, "-s-")); + } + result.addAll(extra); + return result; + } + + private static String normalizePrefixed(String value, String prefix) { + String trimmed = value.trim(); + return trimmed.startsWith(prefix) ? trimmed : prefix + trimmed; + } + /** 时长格式化:ms / s / min / h。 */ private static String formatDuration(long nanos) { double ms = nanos / 1_000_000.0; @@ -518,4 +747,32 @@ public final class ApkDeltaUpdater { 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 [-t|--tier small|medium|large|xlarge|huge] + [-c|--compress ] + [-p|--threads ] + [-- <额外 hdiffz 参数>] + patch [-c|--cache ] [-- <额外 hpatchz 参数>] + info + env + + 示例: + java -jar MavenUtils.jar diff old.apk new.apk app.patch + java -jar MavenUtils.jar diff old.apk new.apk app.patch -t large -c zstd-19 -p 8 + java -jar MavenUtils.jar patch old.apk app.patch restored.apk + java -jar MavenUtils.jar info app.patch + java -jar MavenUtils.jar env + + 说明: + diff 按 old/new 较大者自动选档(64/128/256/512MB),-t 可强制档位; + -c 覆盖档位默认压缩(lzma2 / zstd / bzip2 / no 等); + -p 覆盖 hdiffz 线程数;-- 之后的参数原样传给 hdiffz(如 -d 跳过回验); + patch 按旧包大小选缓存档位,-c 覆盖缓存(如 64m); + -- 之后的参数原样传给 hpatchz; + 二进制按当前平台从内置 Releases(HDiffPatch 5.1.3)解压到临时目录, + 可用 -Dttstd.hdiffpatch.dir=<目录> 指定现成的 hdiffz/hpatchz。"""); + } } diff --git a/src/main/java/com/ttstd/BsDiffTool.java b/src/main/java/com/ttstd/BsDiffTool.java deleted file mode 100644 index 1dc9529..0000000 --- a/src/main/java/com/ttstd/BsDiffTool.java +++ /dev/null @@ -1,172 +0,0 @@ -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 工具。 - * - *

用法:

- *
- *   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
- * 
- * - *

每次 bsdiff / bspatch 执行完成后都会输出耗时。

- */ -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 [-c|--compressor bzip2|gz|pack200|xz] - patch - - 示例: - 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 会自动识别补丁所用的压缩算法。"""); - } -} diff --git a/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_linux64.zip b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_linux64.zip new file mode 100644 index 0000000..fc8f324 Binary files /dev/null and b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_linux64.zip differ diff --git a/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_linux_arm64.zip b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_linux_arm64.zip new file mode 100644 index 0000000..a3e334a Binary files /dev/null and b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_linux_arm64.zip differ diff --git a/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_macos.zip b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_macos.zip new file mode 100644 index 0000000..2233f22 Binary files /dev/null and b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_macos.zip differ diff --git a/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_windows64.zip b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_windows64.zip new file mode 100644 index 0000000..0bd5429 Binary files /dev/null and b/src/main/resources/hdiffpatch/hdiffpatch_v5.1.3_bin_windows64.zip differ