Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -56,17 +63,20 @@ private static final class ZipSource {
void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOException {
HashMap<String, ArrayList<File>> resToCopy = new HashMap<String, ArrayList<File>>();
HashMap<String, Long> crcByFrom = new HashMap<String, Long>();
HashMap<String, String[]> toPathsByFrom = new HashMap<String, String[]>();
for (CopyGroupResult group : groups) {
ArrayList<File> targets = new ArrayList<File>();
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<String> apkPaths = collectApkPaths();
HashSet<String> installedDensities = installedSplitDensities(apkPaths);
ArrayList<SafeZipFile> zipFiles = new ArrayList<SafeZipFile>();
// Entry name -> first archive (base APK first) that carries it.
HashMap<String, ZipSource> availableEntries = new HashMap<String, ZipSource>();
Expand Down Expand Up @@ -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<String> crcMismatches = new ArrayList<String>();
int substitutedDensities = 0;

for (String fromPath : new ArrayList<String>(remainingFiles.keySet())) {
ArrayList<File> targets = remainingFiles.get(fromPath);
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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-<scale>/ 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
Expand Down Expand Up @@ -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,
Expand All @@ -246,6 +301,93 @@ void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOExcept
}
}

private ZipSource[] resolveDensitySubstitutes(
String[] toPaths,
HashSet<String> installedDensities,
HashMap<String, ZipSource> 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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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<String> 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<String> installedSplitDensities(ArrayList<String> apkPaths) {
HashSet<String> densities = new HashSet<String>();
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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> XXHDPI =
new HashSet<String>(Collections.singleton("xxhdpi"));

private static boolean canSubstitute(HashSet<String> installed, String... to) {
return BundledResourceCopier.canSubstituteDensityVariant(to, installed);
}

@Test
public void readsDensitiesFromConfigSplitNames() {
HashSet<String> densities = BundledResourceCopier.installedSplitDensities(
new ArrayList<String>(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<String>(Arrays.asList("xxhdpi", "tvdpi")), densities);
}

@Test
public void plainApkHasNoDensitySplits() {
assertTrue(BundledResourceCopier.installedSplitDensities(
new ArrayList<String>(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<String>(), "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"));
}
}
4 changes: 3 additions & 1 deletion docs/BUNDLEHASH_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 阶段补齐了
稳定码)。
Expand Down
Loading