From 90ff8d92246e45dac3a9de6d92228b8ae18d10d6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Tue, 3 Nov 2026 12:05:09 +0800 Subject: [PATCH 1/2] fix(android): keep pdiff working on density split installs A split install only has the density variants its split carries, so every patch copy of another density looked like content drift and failed the patch. Fill those targets from the variant the resource has installed. Copies of an installed density, and paths present with other content, still fail. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/BundledResourceCopier.java | 145 +++++++++++++++++- .../update/BundledResourceCopierTest.java | 66 ++++++++ docs/BUNDLEHASH_MIGRATION.md | 4 +- 3 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 5eb656ba..01bd2620 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -11,22 +11,29 @@ import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; +import java.util.HashSet; import java.util.zip.ZipEntry; +import java.util.regex.Matcher; import java.util.regex.Pattern; final class BundledResourceCopier { private static final Pattern VERSION_QUALIFIER_PATTERN = Pattern.compile("-v\\d+(?=/)"); private static final String AAB_BASE_PREFIX = "base/"; + // split_config.xxhdpi.apk, split_feature.config.xxhdpi.apk + private static final Pattern DENSITY_SPLIT_PATTERN = + Pattern.compile("(?:^|[._])config\\.((?:ld|md|hd|xhd|xxhd|xxxhd|tvd)pi)\\.apk$"); private final Context context; private static final class ResolvedResourceSource { final int resourceId; final String assetPath; + final int density; - ResolvedResourceSource(int resourceId, String assetPath) { + ResolvedResourceSource(int resourceId, String assetPath, int density) { this.resourceId = resourceId; this.assetPath = assetPath; + this.density = density; } } @@ -56,17 +63,20 @@ private static final class ZipSource { void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOException { HashMap> resToCopy = new HashMap>(); HashMap crcByFrom = new HashMap(); + HashMap toPathsByFrom = new HashMap(); for (CopyGroupResult group : groups) { ArrayList targets = new ArrayList(); for (String to : group.toPaths) { targets.add(new File(targetRoot, to)); } resToCopy.put(group.from, targets); + toPathsByFrom.put(group.from, group.toPaths); if (group.expectedCrc >= 0) { crcByFrom.put(group.from, group.expectedCrc); } } ArrayList apkPaths = collectApkPaths(); + HashSet installedDensities = installedSplitDensities(apkPaths); ArrayList zipFiles = new ArrayList(); // Entry name -> first archive (base APK first) that carries it. HashMap availableEntries = new HashMap(); @@ -116,6 +126,7 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept // these fail the whole patch — the JS strategy chain then falls // back to the full package. ArrayList crcMismatches = new ArrayList(); + int substitutedDensities = 0; for (String fromPath : new ArrayList(remainingFiles.keySet())) { ArrayList targets = remainingFiles.get(fromPath); @@ -127,6 +138,7 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept String actualSourcePath = fromPath; ResolvedResourceSource resolvedResource = null; Long wantedCrc = crcByFrom.get(fromPath); + boolean pathMatchDrifted = false; if (entry == null) { String normalizedFrom = normalizeResPath(fromPath); @@ -147,6 +159,7 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept && entry.entry.getCrc() != wantedCrc.longValue()) { entry = null; actualSourcePath = fromPath; + pathMatchDrifted = true; } // Content (CRC32) match: robust across APK/AAB packaging because @@ -160,6 +173,39 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept } } + // A density split install only carries the device's density, + // so the expected bytes of another density cannot be on disk. + // That is not drift: fill the targets with the installed + // variant, because the JS side picks drawable-/ by the + // current PixelRatio and a missing file is a broken image. + if (entry == null && wantedCrc != null && !pathMatchDrifted) { + String[] toPaths = toPathsByFrom.get(fromPath); + ZipSource[] substitutes = resolveDensitySubstitutes( + toPaths, installedDensities, availableEntries + ); + if (substitutes != null) { + for (int i = 0; i < substitutes.length; i++) { + File target = targets.get(i); + try { + substitutes[i].zipFile.unzipToFile(substitutes[i].entry, target); + substitutedDensities++; + } catch (IOException e) { + Log.e( + Texts.LOG_TAG, + "Failed to copy resource " + + substitutes[i].entry.getName() + + " to " + + target, + e + ); + failedCopies.add(substitutes[i].entry.getName() + " -> " + target); + } + } + remainingFiles.remove(fromPath); + continue; + } + } + // A declared CRC that no installed entry satisfies is a hard // content mismatch. The resource-id heuristic below cannot be // trusted here: the content index above already covers every @@ -235,6 +281,15 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept ); } + if (substitutedDensities > 0) { + Log.i( + Texts.LOG_TAG, + "Filled " + substitutedDensities + + " density variants missing from this split install (" + + installedDensities + ") with the installed variant" + ); + } + if (!remainingFiles.isEmpty()) { Log.w( Texts.LOG_TAG, @@ -246,6 +301,90 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept } } + private ZipSource[] resolveDensitySubstitutes( + String[] toPaths, + HashSet installedDensities, + HashMap availableEntries + ) { + if (!canSubstituteDensityVariant(toPaths, installedDensities)) { + return null; + } + ZipSource[] substitutes = new ZipSource[toPaths.length]; + for (int i = 0; i < toPaths.length; i++) { + ResolvedResourceSource resolved = resolveBundledResource(toPaths[i]); + // Resolving to the target's own density means this very variant is + // installed, so its bytes should have matched: that is drift. So is + // a resource the binary no longer has. bundletool can place another + // density's variant in the device's split, so the split name alone + // does not settle this. + if (resolved == null + || Integer.valueOf(resolved.density).equals(densityOf(toPaths[i]))) { + return null; + } + substitutes[i] = availableEntries.get(resolved.assetPath); + if (substitutes[i] == null) { + return null; + } + } + return substitutes; + } + + /** + * Whether copy targets can be outside what a density split install carries. + * Only the targets can tell: release APKs shorten res/ paths (res/5u.png), + * so the manifest's source path says nothing about its density. + */ + static boolean canSubstituteDensityVariant( + String[] toPaths, + HashSet installedDensities + ) { + if (installedDensities.isEmpty() || toPaths == null || toPaths.length == 0) { + return false; + } + for (String toPath : toPaths) { + String directory = resourceDirectory(toPath); + if (!toPath.startsWith("drawable-") + || densityOf(toPath) == null + || installedDensities.contains(densityQualifier(directory))) { + return false; + } + } + return true; + } + + private static Integer densityOf(String resourcePath) { + String directory = resourceDirectory(resourcePath); + return directory == null ? null : parseDensityQualifier(directory); + } + + private static String densityQualifier(String directory) { + for (String qualifier : directory.split("-")) { + if (parseDensityQualifier(qualifier) != null) { + return qualifier; + } + } + return null; + } + + private static String resourceDirectory(String resourcePath) { + String path = resourcePath.startsWith("res/") + ? resourcePath.substring("res/".length()) + : resourcePath; + int slash = path.indexOf('/'); + return slash <= 0 ? null : path.substring(0, slash); + } + + static HashSet installedSplitDensities(ArrayList apkPaths) { + HashSet densities = new HashSet(); + for (String apkPath : apkPaths) { + Matcher matcher = DENSITY_SPLIT_PATTERN.matcher(new File(apkPath).getName()); + if (matcher.find()) { + densities.add(matcher.group(1)); + } + } + return densities; + } + private String normalizeResPath(String path) { String result = path; if (result.startsWith(AAB_BASE_PREFIX)) { @@ -273,7 +412,7 @@ private String extractResourceName(String fileName) { return fileName.substring(0, extensionIndex); } - private Integer parseDensityQualifier(String directoryName) { + private static Integer parseDensityQualifier(String directoryName) { String[] qualifiers = directoryName.split("-"); for (String qualifier : qualifiers) { if ("ldpi".equals(qualifier)) { @@ -347,7 +486,7 @@ private ResolvedResourceSource resolveBundledResource(String resourcePath) { assetPath = assetPath.substring(1); } - return new ResolvedResourceSource(resourceId, assetPath); + return new ResolvedResourceSource(resourceId, assetPath, typedValue.density); } private InputStream openResolvedResourceStream(ResolvedResourceSource source) throws IOException { diff --git a/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java b/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java new file mode 100644 index 00000000..b5e39f2c --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java @@ -0,0 +1,66 @@ +package cn.reactnative.modules.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import org.junit.Test; + +public class BundledResourceCopierTest { + private static final HashSet XXHDPI = + new HashSet(Collections.singleton("xxhdpi")); + + private static boolean canSubstitute(HashSet installed, String... to) { + return BundledResourceCopier.canSubstituteDensityVariant(to, installed); + } + + @Test + public void readsDensitiesFromConfigSplitNames() { + HashSet densities = BundledResourceCopier.installedSplitDensities( + new ArrayList(Arrays.asList( + "/data/app/x/base.apk", + "/data/app/x/split_config.arm64_v8a.apk", + "/data/app/x/split_config.zh.apk", + "/data/app/x/split_config.xxhdpi.apk", + "/data/app/x/split_feature.config.tvdpi.apk" + )) + ); + assertEquals(new HashSet(Arrays.asList("xxhdpi", "tvdpi")), densities); + } + + @Test + public void plainApkHasNoDensitySplits() { + assertTrue(BundledResourceCopier.installedSplitDensities( + new ArrayList(Collections.singletonList("/data/app/x/base.apk")) + ).isEmpty()); + } + + @Test + public void otherDensityOfASplitInstallCanBeSubstituted() { + assertTrue(canSubstitute(XXHDPI, "drawable-mdpi/a.png")); + assertTrue(canSubstitute(XXHDPI, "drawable-xhdpi/a.png", "drawable-hdpi/b.png")); + } + + @Test + public void withoutADensitySplitEveryDensityShouldBeInstalled() { + assertFalse(canSubstitute(new HashSet(), "drawable-mdpi/a.png")); + } + + @Test + public void targetAtInstalledDensityIsDrift() { + assertFalse(canSubstitute(XXHDPI, "drawable-xxhdpi/a.png")); + assertFalse(canSubstitute(XXHDPI, "drawable-mdpi/a.png", "drawable-xxhdpi/b.png")); + } + + @Test + public void targetsWithoutADensityAreDrift() { + assertFalse(canSubstitute(XXHDPI, "assets/a.png")); + assertFalse(canSubstitute(XXHDPI, "raw/a.bin")); + assertFalse(canSubstitute(XXHDPI, "drawable-nodpi/a.png")); + assertFalse(canSubstitute(XXHDPI, "drawable/a.png")); + } +} diff --git a/docs/BUNDLEHASH_MIGRATION.md b/docs/BUNDLEHASH_MIGRATION.md index ab7ad871..485edcf9 100644 --- a/docs/BUNDLEHASH_MIGRATION.md +++ b/docs/BUNDLEHASH_MIGRATION.md @@ -106,7 +106,9 @@ 2. `copiesCrc` 扩展到**全部** copies 条目(CLI 2.21.2); 3. 客户端拷贝前校验,不符 → 整次 pdiff 失败、JS 策略链落 full,**永不装出坏包**: Android `BundledResourceCopier`、iOS/共享 C++ `CopyOperation.expected_crc` - (`ApplyPatchFromFileSource`,`digest::Crc32File`)、Harmony 经 NAPI `crc32`; + (`ApplyPatchFromFileSource`,`digest::Crc32File`)、Harmony 经 NAPI `crc32`。 + 例外:Android 密度分包安装里,其他密度本来就不在设备上,不算漂移,目标文件用该资源 + 已安装的变体填充;已安装密度或路径存在但内容不符的条目仍然失败; 4. 遥测:本地事件 `downloadFallback`(增量失败但后续策略救回),失败码为 PATCH_FAILED 时 映射服务端 `patch_fail`(为此 Android `PatchFailedException`、Harmony patch 阶段补齐了 稳定码)。 From 113e3bdcf46e67afb1349009b0f598ea15778407 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Tue, 3 Nov 2026 12:30:01 +0800 Subject: [PATCH 2/2] fix(android): only substitute plain density directories A target like drawable-night-hdpi resolved from the current configuration, so a day variant could land in the night directory. Such targets fail as drift. Co-Authored-By: Claude Opus 5.5 --- .../reactnative/modules/update/BundledResourceCopier.java | 3 +++ .../modules/update/BundledResourceCopierTest.java | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 01bd2620..3992c5a4 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -345,6 +345,9 @@ static boolean canSubstituteDensityVariant( String directory = resourceDirectory(toPath); if (!toPath.startsWith("drawable-") || densityOf(toPath) == null + // Another qualifier (drawable-night-hdpi) would not survive the + // lookup, which resolves those from the current configuration. + || !directory.equals("drawable-" + densityQualifier(directory)) || installedDensities.contains(densityQualifier(directory))) { return false; } diff --git a/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java b/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java index b5e39f2c..491239a9 100644 --- a/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java @@ -56,6 +56,13 @@ public void targetAtInstalledDensityIsDrift() { assertFalse(canSubstitute(XXHDPI, "drawable-mdpi/a.png", "drawable-xxhdpi/b.png")); } + @Test + public void targetsWithAnotherQualifierAreDrift() { + assertFalse(canSubstitute(XXHDPI, "drawable-night-hdpi/a.png")); + assertFalse(canSubstitute(XXHDPI, "drawable-hdpi-v21/a.png")); + assertFalse(canSubstitute(XXHDPI, "drawable-mdpi/a.png", "drawable-land-hdpi/b.png")); + } + @Test public void targetsWithoutADensityAreDrift() { assertFalse(canSubstitute(XXHDPI, "assets/a.png"));