diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..9ccd54a --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + com.ttstd + MavenUtils + 1.0-SNAPSHOT + + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.ttstd.BsDiffTool + + + + + + + + + + UTF-8 + 25 + 25 + + diff --git a/pom.xml b/pom.xml index f2207fb..36ef263 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,13 @@ lunar 1.7.7 + + + io.sigpipe + jbsdiff + 1.0 + 25 @@ -20,4 +27,30 @@ UTF-8 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + com.ttstd.BsDiffTool + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/ttstd/ApkDeltaUpdater.java b/src/main/java/com/ttstd/ApkDeltaUpdater.java new file mode 100644 index 0000000..fe47072 --- /dev/null +++ b/src/main/java/com/ttstd/ApkDeltaUpdater.java @@ -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 省流增量更新:按文件大小分档,自动选择差量引擎与执行模式。 + * + *

一、工具 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 不可用,排除
+ *

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

+ * + *

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

+ * + * + * + * + * + * + * + * + *
档位大小首选引擎模式压缩单条目上限
SMALL< 64MBHDiffPatch整包bzip2-
MEDIUM64~128MBxdelta3整包gzip-
LARGE128~256MBbsdiff条目级bzip264MB
XLARGE256~512MBbsdiff条目级bzip232MB
HUGE> 512MBbsdiff条目级bzip216MB
+ *

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

+ * + *

三、条目级 patch 容器格式

+ *

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

+ *
+ *   COPY  <name>                     old 同名同 CRC,原样拷贝
+ *   ADD   <name>  <dataEntry>      old 无同名或超上限,直接携带
+ *   DIFF  <name>  <dataEntry>      old 条目 + 容器内 bsdiff 补丁还原
+ * 
+ */ +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 oldDigests = scanDigests(oldFile); + try (ZipFile oldZip = new ZipFile(oldFile); + ZipFile newZip = new ZipFile(newFile); + ZipOutputStream patchOut = new ZipOutputStream( + new BufferedOutputStream(new FileOutputStream(patchFile)))) { + + 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); + } + } + 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 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; + } + 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); + } +} diff --git a/src/main/java/com/ttstd/BsDiffTool.java b/src/main/java/com/ttstd/BsDiffTool.java new file mode 100644 index 0000000..1dc9529 --- /dev/null +++ b/src/main/java/com/ttstd/BsDiffTool.java @@ -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 工具。 + * + *

用法:

+ *
+ *   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 会自动识别补丁所用的压缩算法。"""); + } +}