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..3992c5a4 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,93 @@ 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 + // 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; + } + } + 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 +415,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 +489,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..491239a9 --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/BundledResourceCopierTest.java @@ -0,0 +1,73 @@ +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 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")); + 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 阶段补齐了 稳定码)。