react-native-update 10.48.3 → 10.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/BUNDLEHASH_MIGRATION.md +355 -0
  2. package/NATIVE_CHECKUPDATE_DESIGN.md +192 -0
  3. package/android/jni/Android.mk +1 -0
  4. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  5. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  6. package/android/lib/x86/librnupdate.so +0 -0
  7. package/android/lib/x86_64/librnupdate.so +0 -0
  8. package/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +42 -9
  9. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +22 -1
  10. package/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +16 -0
  11. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +125 -0
  12. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +23 -3
  13. package/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  14. package/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +5 -0
  15. package/cpp/patch_core/digest.cpp +230 -0
  16. package/cpp/patch_core/digest.h +61 -0
  17. package/cpp/patch_core/patch_core.cpp +17 -1
  18. package/cpp/patch_core/patch_core.h +8 -0
  19. package/cpp/patch_core/tests/patch_core_test.cpp +169 -0
  20. package/harmony/pushy/src/main/cpp/CMakeLists.txt +1 -0
  21. package/harmony/pushy/src/main/cpp/pushy.cpp +94 -0
  22. package/harmony/pushy/src/main/ets/DownloadTask.ts +85 -0
  23. package/harmony/pushy/src/main/ets/NativePatchCore.ts +4 -0
  24. package/harmony/pushy/src/main/ets/PushyTurboModule.ts +11 -0
  25. package/harmony/pushy/src/main/ets/UpdateContext.ts +79 -4
  26. package/harmony/pushy.har +0 -0
  27. package/ios/RCTPushy/RCTPushy.mm +116 -7
  28. package/package.json +1 -1
  29. package/react-native-update.podspec +1 -0
  30. package/src/NativePushy.ts +6 -0
  31. package/src/client.ts +119 -7
  32. package/src/core.ts +27 -0
  33. package/src/endpoint.ts +98 -51
  34. package/src/error.ts +4 -0
  35. package/src/isInRollout.ts +2 -1
  36. package/src/locales/en.ts +6 -0
  37. package/src/locales/zh.ts +6 -0
  38. package/src/provider.tsx +36 -7
  39. package/src/telemetry.ts +8 -0
  40. package/src/type.ts +34 -3
  41. package/src/utils.ts +13 -0
@@ -0,0 +1,355 @@
1
+ # bundleHash 迁移执行方案
2
+
3
+ > 状态:**Phase 0/1/2 已实现、已提交;两台服务端已完成生产迁移与部署(2026-07-29)**
4
+ > - DB:pushy 北京 RDS 与 cresc Cloud SQL 均已加 `packages.bundleHash` 列(幂等 runner 在 `scripts/migrate-add-package-bundle-hash.ts`;cresc 经一次性 Cloud Run Job 进 VPC 执行)
5
+ > - 服务:pushy `p`/`s1` @ `2026.7.29-a2ecac26`,cresc api/worker/dailyjob @ `2026.7.29-3aabff91`,线上带 `bundleHash` 冒烟均通过
6
+ > - **`bundleHashJudge.enabled` 仍为关**——待 CLI/客户端发版、覆盖率起来后再开
7
+ > - **客户端与 CLI 已发版(2026-07-30)**:`react-native-update@10.49.0`、`react-native-update-cli@2.20.3`。全链路(客户端上报 → CLI 注册 → 服务端判定基建)就位,仅待覆盖率与开开关
8
+ > - **`bundleHashJudge.enabled` 已于 2026-08-03 双端开启**(pushy + cresc,redis `config_dynamic`,回退=写回 false)。同日:CLI 2.21.2 补全 copiesCrc(§4.2.1 第 2 条)并随两服务端部署;客户端三端 CRC 校验 + fallback 遥测(§4.2.1 第 3/4 条)已实现、待随下个客户端版本发布
9
+ > - Phase 2 遗留见 §4.5,Phase 3 待观察期后
10
+ > 依据:`BUNDLEHASH_DESIGN.md`(设计定稿)。本文只讲**怎么落地**,不重复论证为什么。
11
+ > 下游:`NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉必须等本迁移完成,否则协议下沉后再改协议要返工。
12
+
13
+ ---
14
+
15
+ ## 0. 一条贯穿全程的铁律
16
+
17
+ **客户端哈希的文件,必须与 pdiff 实际打补丁的那个文件字节一致。**
18
+
19
+ 不一致就是最坏结果:服务端确信 pdiff 可用、下发、下载完 hpatch 源校验失败,比现在的启发式还糟。
20
+
21
+ **所以两侧都不要新写"找 bundle 文件"的逻辑,复用 pdiff 实际的源读取路径**:
22
+
23
+ | | 客户端哈希的对象 | pdiff 的源读取(哈希必须与之逐字对齐) |
24
+ |---|---|---|
25
+ | iOS | `[RCTPushy binaryBundleURL]` 返回的文件(`mainBundle` 的 `main.jsbundle`) | 就是 pdiff 的 `fromBundle`(`RCTPushy.mm:727`) |
26
+ | Android | **硬编码 `index.android.bundle`**,`context.getAssets().open()` 读 | `DownloadTask.copyBundledAssetToFile("index.android.bundle", …)`(`DownloadTask.java:322`)——pdiff 源本来就是硬编码的,**不走** `getBundleAssetName()` 反射;自定义 bundle 名的用户 pdiff 今天就打不了、CLI 也传不上(`getApkInfo` 的正则同样硬编码),三处一致地退化到 buildTime 启发式 |
27
+ | Harmony | rawfile `bundle.harmony.js` | pdiff 的 `getRawFileContent('bundle.harmony.js')`(`DownloadTask.ts:559`) |
28
+ | CLI | 解包后的同名条目 | `getIpaInfo` / `getApkInfo` / `getAppInfo` **已经把 bundleFile 拿在手里了** |
29
+
30
+ 最后一行是这次调研最大的好消息,见 §3。
31
+
32
+ ---
33
+
34
+ ## 1. 实施前定稿(原设计 §11 的四个开放问题)
35
+
36
+ | # | 问题 | 定稿 |
37
+ |---|---|---|
38
+ | 1 | 字段名 | `bundleHash` / `bundleStatus` / `digests`,维持原议 |
39
+ | 2 | `digests` 用 sha256 还是复用 OSS md5 | **sha256**。OSS 的 etag 在分片上传下不是内容 md5,不能直接复用;且 §0 的 sha256 工具本来就要进 C++,复用同一套心智 |
40
+ | 3 | 控制台聚合口径 | 按 **(app, packageVersion)**。因为对应的开发者动作就是"把这个包传上来",按 app 聚合无法定位到具体动作 |
41
+ | 4 | `assetsHash` | **不做**。metro 把受管资源的内容 md5 嵌进 bundle,资源变则 bundle 字节必变,需求趋近于零。等真实反馈 |
42
+
43
+ ---
44
+
45
+ ## 2. Phase 0 — 客户端(react-native-update)
46
+
47
+ ### 2.1 sha256 工具进 `cpp/patch_core`——但 Android 用 `MessageDigest`
48
+
49
+ `cpp/patch_core/digest.{h,cpp}`,流式接口 + `Sha256File`,同时为 §8 的下载校验铺路:
50
+
51
+ ```cpp
52
+ namespace pushy::digest {
53
+ class Sha256 { void Update(const uint8_t*, size_t); std::string HexDigest(); };
54
+ std::string Sha256File(const std::string& path); // iOS 用
55
+ }
56
+ ```
57
+
58
+ 各端消费方式(审查后修订,原稿要求三端全走 C++):
59
+
60
+ | 平台 | 实现 | 理由 |
61
+ |---|---|---|
62
+ | iOS | `pushy::digest`(podspec 已从源码编译 patch_core,加一个文件即可) | 免费共享 |
63
+ | Harmony | NAPI 包装 `pushy::digest`(CMakeLists 同样从源码编译) | 免费共享 |
64
+ | **Android** | **`java.security.MessageDigest("SHA-256")`** | `librnupdate.so` 是预编译产物,为这一个函数重编 4 ABI 不值;SHA-256 是标准算法,C++ 侧的 NIST 测试向量同时锚定两个实现 |
65
+
66
+ **收益:Phase 0 完全不需要重编 `.so`**,原方案的 R4(ABI/对齐回归)风险在本阶段消失。digest.cpp 照常加进 `.so` 的构建脚本,下次因其他原因重编时自动带上,Phase 3 的 C++ 侧校验直接可用。
67
+
68
+ ### 2.2 缓存键:设计文档这里是错的
69
+
70
+ 原设计写"缓存键 `(packageVersion, bundle 文件 size, mtime)`"。**Android 和 Harmony 的 bundle 不是磁盘文件**——一个在 APK 的 assets 里,一个在 rawfile 里,都没有独立的 size/mtime 可读。
71
+
72
+ 改为按平台取各自的"安装身份":
73
+
74
+ | 平台 | 缓存键 | 读取方式 |
75
+ |---|---|---|
76
+ | iOS | `(packageVersion, 文件 size, mtime)` | `main.jsbundle` 是真实文件,原设计可用 |
77
+ | Android | `(packageVersion, PackageInfo.lastUpdateTime)` | 每次安装/覆盖安装都会变,含同版本重装 |
78
+ | Harmony | `(packageVersion, BundleInfo.updateTime)` | 已核对 `BundleInfo.d.ts:390`,字段存在 |
79
+
80
+ 键存 SharedPreferences / NSUserDefaults / preferences,与现有状态同一存储。
81
+
82
+ ### 2.3 读取与计算
83
+
84
+ - **Android**:`context.getAssets().open("index.android.bundle")` 流式喂给 `MessageDigest`——**与 pdiff 源读取(`copyBundledAssetToFile`)完全同一 API、同一硬编码名**(§0)。资源不存在(debug / 自定义名 / 无嵌入 bundle)→ 返回空串。
85
+ - **iOS**:`Sha256File([[RCTPushy binaryBundleURL] path])`;文件不存在 → 空串。
86
+ - **Harmony**:`resourceManager.getRawFileContent('bundle.harmony.js')` 拿到 buffer 后经 NAPI 喂给 sha256(与 pdiff 读法完全一致)。
87
+
88
+ 线程:Android `StateSerialRunner`,iOS `_fileQueue`,Harmony taskpool。几 MB 约 10-50ms。
89
+
90
+ ### 2.4 暴露给 JS
91
+
92
+ **异步 getter,不进 `getConstants`**(AN-6/IO-4 的教训):
93
+
94
+ ```ts
95
+ // NativePushy.ts
96
+ getBundleHash(): Promise<string>; // 计算失败 / debug 构建返回空串
97
+ ```
98
+
99
+ ### 2.5 JS 接入:预取 + 同步读,checkUpdate 零 await
100
+
101
+ 初稿在 checkUpdate 里做 200ms 超时 race,评审否决——没必要的复杂度。定稿:
102
+
103
+ ```ts
104
+ // core.ts 模块加载时 fire-and-forget 预取,settle 后存模块变量
105
+ if (typeof PushyModule.getBundleHash === 'function') {
106
+ Promise.resolve(PushyModule.getBundleHash()).then((h) => { bundleHash = String(h || ''); });
107
+ }
108
+ export const getBundleHash = (): string => bundleHash;
109
+
110
+ // client.ts checkUpdate 内:同步读,没有就省略
111
+ const bundleHash = __DEV__ ? '' : getBundleHash();
112
+ ```
113
+
114
+ 拿不到就省略字段,服务端自动退回 buildTime 启发式(判定矩阵第 4 行),下一次检查自然带上。代价是每个进程的**第一次** checkUpdate 可能缺字段(原生缓存命中时预取通常几 ms 内 settle,实际很少缺)——一次退化完全可接受。
115
+
116
+ **顺带消掉一个曾踩过的坑**:初稿的 race `await` 插进了"5s 去重检查 → `lastRespJson` 赋值"的同步窗口,导致并发 checkUpdate 双发请求(JS2-1 测试抓住)。同步读方案下 checkUpdate 完全没有新增 await,这类问题在结构上不可能出现。
117
+
118
+ ### 2.6 `bundleStatus` 消费(字段先行)
119
+
120
+ 服务端上线前不会有这个字段,先把消费侧写好:
121
+ - `unknownBundle` → `warn()`(新 i18n key `warn_unknown_bundle`)+ `report({ type: 'bundleMismatch' })` 进 logger
122
+ - `afterCheckUpdate` 的 `UpdateCheckState` 透传 `bundleStatus`(result 自带字段,无需额外代码)
123
+
124
+ **修订原设计 §7 触达第 1 条**:\"`__DEV__` 下 console.warn 联调期拦截\"实际不可达——dev 构建不上报 bundleHash(metro bundle 无嵌入产物),服务端按判定矩阵第 4 行走 buildTime 启发式,永远不会返回 `unknownBundle`。warn 实际只出现在 release 日志里;开发者触达的主渠道是控制台聚合(触达第 3 条)与 CLI 前置拦截(第 4 条),不受影响。
125
+
126
+ ### 2.7 验收
127
+
128
+ - ✅ C++ sha256 NIST 向量测试(`bun test:patch-core`,26/26)
129
+ - ✅ `bun test`(122,含 5 条新 bundleHash/bundleStatus 用例)/ `bun lint`(biome + tsc + harmony tsc)
130
+ - ✅ `javac` 对 RN 0.85.2 AAR(main + oldarch)/ `clang -fsyntax-only` 对 e2etest Pods
131
+ - ✅ **iOS + Android 模拟器 e2e**(`Example/e2etest/e2e/bundle-hash.test.ts`,已入 detox 套件、随 e2e_ios / e2e_android CI 跑):release 构建、真机路径全链路,原生算出的值与宿主侧对包内 bundle 算的 sha256 **逐字节一致**——
132
+ - iOS:JS 预取 → `_fileQueue` 懒计算 → C++ digest → NSUserDefaults;宿主 `simctl get_app_container` + `plutil` 直读容器 plist 比对 `sha256(main.jsbundle)`;含缓存命中路径
133
+ - Android:`AssetManager.open` 解压字节 == APK zip 条目字节(bundle 压缩存储,这是 iOS 覆盖不到的独立风险点)、`MessageDigest` == 标准 sha256;宿主 `adb root` 读 `shared_prefs/update.xml` 比对 `unzip -p` 提取值;gradle 构建顺带完成 newarch codegen 对 `getBundleHash` 的真实编译验证
134
+ - ⬜ Harmony 真机比对未做:NAPI 直连已过 NIST 向量的 C++ digest,读法与 pdiff 完全同源(`getRawFileContent`),风险最低
135
+ - **e2e 踩坑记录**:`simctl spawn defaults read` 看不到 app 容器 defaults(必须 `get_app_container` + `plutil -extract`);detox jest 环境把全局 `expect` 换成元素断言版(普通值断言用显式比较);staged 目录为空壳时 gradle autolinking 缓存会记住"无 android 平台",重 stage 后须清 `android/build/generated/autolinking`;Android release 下 detox `launchApp` 的会话握手在本机 arm64 模拟器挂起——本用例不需要 UI 交互,Android 分支用 `adb shell monkey` 拉起绕开
136
+
137
+ ---
138
+
139
+ ## 3. Phase 1 — CLI(react-native-update-cli)
140
+
141
+ **成本远低于设计文档的预期。** `getIpaInfo` / `getApkInfo` / `getAppInfo`(`src/utils/index.ts:145/187/230`)为了校验"包里有没有 bundle",**已经把 bundle 的 Buffer 读出来了**,而且缺失时已经报错。
142
+
143
+ 所以每个函数加一行:
144
+
145
+ ```ts
146
+ return { versionName, buildTime, bundleHash: sha256(bundleFile), ...appCredential };
147
+ ```
148
+
149
+ 再在 `uploadNativePackage`(`src/package.ts:170`)的 `package/create` 请求体里带上 `bundleHash`。
150
+
151
+ 另外两件事:
152
+ - AAB **不需要单独处理**:`uploadAab` 是先 `AabParser.extractApk` 转 APK 再委托 `uploadApk`(`package.ts:258`),自动经过 `getApkInfo`
153
+ - 上传时若同 `versionName` 已存在**不同** `bundleHash` 的包记录 → 提示"重打包且 JS 有变"(设计 §7 第 4 条)。这需要服务端先提供查询,**可以延后到 Phase 2 之后**,不阻塞
154
+
155
+ 老服务端会忽略未知字段——已验证:`pushy-server` 的 `dto.ts:280` 注释明确"未知字段由 Elysia 默认 normalize 剥离,保留前向兼容"。Phase 1 可以独立发布。
156
+
157
+ ---
158
+
159
+ ## 4. Phase 2 — 服务端(pushy-server)
160
+
161
+ ### 4.1 最容易漏、漏了最严重的一条
162
+
163
+ **`buildCheckUpdateCacheKey` 必须加 `bundleHash`。**(`code/src/utils/checkUpdateCapabilities.ts:51`)
164
+
165
+ 现在的键是 `[appId, checkUpdateRevision, packageVersion, fromHash, buildTime, cacheBucket, toHash]`。一旦响应内容开始随 `bundleHash` 变化(给不给 pdiff、`bundleStatus` 是什么),不进键就是**跨设备串响应**:一个装了未注册二进制的设备可能拿到另一个设备的"给 pdiff"缓存,直接踩中 §0 那个最坏结果。
166
+
167
+ 基数不会明显上升——`bundleHash` 与 `buildTime` 基本一一对应,Phase 3 删掉 buildTime 后反而回落。
168
+
169
+ ### 4.2 数据与判定
170
+
171
+ - `packages` 表加 `bundleHash VARCHAR(64)`(`prisma/schema.prisma:60`),`getCachedCheckUpdatePackage` 的 select 加该字段
172
+ - 判定矩阵(设计 §6)落在 `handleCheckUpdate.ts` 的 `checkAndGenerateTaskAndCdnUrl` 之前:决定"这次允不允许下发 pdiff"
173
+ - `digests` 下发 + `__diff.json` 的 copies 附摘要
174
+
175
+ ### 4.2.1 不实现 `touchesPackageAssets`(推翻原设计 §6)
176
+
177
+ 原设计要求 pdiff 生成时预计算 `touchesPackageAssets`(manifest 是否含"从包内拷非 bundle 文件"),用于门控"bundleHash 命中但 buildTime 不符"这一行。**该标记不应实现**:
178
+
179
+ 查 CLI 的 pdiff 生成(`react-native-update-cli/src/diff.ts:695-710`):
180
+
181
+ ```js
182
+ // If same file.
183
+ if (originEntries[entry.fileName] === entry.crc32) {
184
+ copies[entry.fileName] = ''; // 同路径拷贝
185
+ return;
186
+ }
187
+ const movedFrom = originMap[entry.crc32];
188
+ if (movedFrom) {
189
+ copies[entry.fileName] = movedFrom; // 按内容定位拷贝
190
+ return;
191
+ }
192
+ await addFileFromZipEntry(...); // 只有新文件才打进 patch 包
193
+ ```
194
+
195
+ **ppk 里每一个与二进制包 CRC32 相同的非 bundle 文件都会进 `copies`**。任何带图片/字体的真实应用,该标记恒为 true → "重打包但 JS 未变"这一行永远拿不到 pdiff。
196
+
197
+ 而这一行正是设计 §1 点名的最常见场景、整个方案的头号收益来源。**这个机制会把它自己要解决的主场景关掉。**
198
+
199
+ 它防的场景设计文档自己也判定为 ≈ 不存在:metro 把受管资源的内容 hash 嵌进 bundle,资源变则 bundle 字节必变、bundleHash 不会命中,根本落不到这一行。
200
+
201
+ **代之以运行时保证**(设计 §8 的 apply 校验,已建好一半):
202
+
203
+ 1. 该行直接下发 pdiff
204
+ 2. `copiesCrc`(`diff.ts:645`,目前只记 "moved" 条目,为 APK→AAB 路径变化而设)扩展到**全部** copies 条目
205
+ 3. 客户端拷贝时校验,不符 → fallback full。代价退化为一次 pdiff 下载,**永不装出坏包**
206
+ 4. 给该 fallback 加遥测
207
+
208
+ **已全部落地(2026-08-03)**:
209
+ - 第 2 条:CLI 2.21.2(同路径分支补记 CRC;两服务端已 bump 部署,worker 新生成的 pdiff 均带全量 copiesCrc)
210
+ - 第 3 条(三端):Android `BundledResourceCopier` 路径命中也比 CRC,不符 → 内容定位 → 仍无 → 抛错;iOS/共享 C++ `CopyOperation.expected_crc` 在 `ApplyPatchFromFileSource` 拷贝前校验(`digest::Crc32File`,NIST/check-value 测试锚定);Harmony pdiff 拷贝声明 CRC 的条目改走字节路径经 NAPI `crc32` 校验。三端不符均使整次 pdiff 失败,JS 策略链自动落 full
211
+ - 第 4 条:新增本地事件 `downloadFallback`(增量策略失败但后续策略救回),失败码为 PATCH_FAILED 时映射服务端 `patch_fail`(网络噪音不上报)。为此补齐了 Android(`PatchFailedException`:下载完成后的失败 reject 为 PATCH_FAILED,此前一律 DOWNLOAD_FAILED)与 Harmony(patch 阶段 `error.code='PATCH_FAILED'`)的稳定码
212
+ - 注意:存量已生成的 pdiff 只有 moved 条目的 CRC,客户端只校验有声明的条目——旧 pdiff 行为不变,随版本滚动自然收敛
213
+
214
+ 若遥测显示这一行的 pdiff 失败率显著,再引入**精确**判据(如"copies 含非 metro 资源"),而不是用恒为 true 的粗标记一刀切。
215
+
216
+ ### 4.3 开关与灰度
217
+
218
+ 判定逻辑挂 `dynamicConfig` 开关,默认关。开启后异常可一键退回 buildTime 启发式——**这是 Phase 2 唯一的回退手段,必须先有开关再有逻辑**。
219
+
220
+ ### 4.4 实现状态与两处实施决策
221
+
222
+ 已落地(`pushy-server` 工作区,未提交,`bun test` 841 全绿):
223
+
224
+ - `packages.bundleHash VARCHAR(64) NULL`(schema + `migrations/add_package_bundle_hash.sql`);`package/create` 接收并存储(DTO 带 `^[0-9a-f]{64}$` 校验)
225
+ - `checkUpdateBodyJson` 接收 `bundleHash`;`buildCheckUpdateCacheKey` 进键并 bump `cu:v3→v4`;`cu:manifest` 也 bump `v1→v2`(select 加了字段,避免旧缓存形状被误判为"老 CLI 上传")
226
+ - 判定矩阵在 `handleCheckUpdate`,`dynamicConfig.bundleHashJudge.enabled` 门控(默认关);`unknownBundle` 的 pdiff 扣发复用 artifact 状态机的 `'empty'` 语义(天然做到"不下发、不入队")
227
+ - 测试:矩阵五行 + 开关关死回退 + 缓存键分裂,7 条新用例
228
+
229
+ 实施决策两处(文档此前未明确):
230
+
231
+ 1. **`unknownBundle` 不再 block**。今天 buildTime 不符是 6 小时 block(`ApiErrorWithOp`);矩阵命中后同样场景变为"扣 pdiff、照发 full",终端用户无感知。这是行为放宽,也是设计本意(§7"所有分支自动降级到正确路径")。
232
+ 2. **`ignoreBuildTime` 的 app 在开关开启后变严**:此前它无条件发 pdiff,现在内容不符会扣发。正确——内容不符的 pdiff 下载后 hpatch 源校验必败,本来就是白费流量。
233
+
234
+ ### 4.4.1 cresc-server 同步(两态矩阵版)
235
+
236
+ cresc-server 是独立仓库(结构同构、历史独立,`handleCheckUpdate` 已漂移约 700 行),Phase 2 已手工移植(`bun test` 760 全绿)。关键差异:
237
+
238
+ - **cresc 没有 buildTime 启发式**(连 `ignoreBuildTime` 都已删除,见其 `drop_apps_ignore_build_time.sql`),今天 pdiff 是**无条件信任**的——正是设计 §1 "过松"失效模式的现役实例:不匹配的二进制会白下载 pdiff、hpatch 源校验失败再回落 full。bundleHash 判定对 cresc 是**第一道真正的门控**
239
+ - 矩阵塌缩为**两态**:`matched` / `unknownBundle`(rebuiltSameJs 的定义依赖 buildTime 分歧,无 buildTime 则与 matched 不可区分,也无需区分——客户端对二者都静默)
240
+ - 回退语义不同:开关关闭 = 回到"无条件信任 pdiff"(pushy 端是回到 buildTime 启发式)
241
+ - 其余同构:`packages.bundleHash` 列 + migration、DTO、`cu:v3→v4`、`cu:manifest:v1→v2`、`'empty'` 语义扣发、`dynamicConfig.bundleHashJudge.enabled` 默认关
242
+
243
+ 注:cresc-server 干净树上有两个**存量** TS 错误(`src/routers/user.ts:916/957`,email 变更通知的 audit extra 字段),与本次改动无关,未处理。
244
+
245
+ ### 4.5 Phase 2 遗留(不阻塞开启开关)
246
+
247
+ - ~~控制台 unknownBundle 聚合面板~~ **已实现(2026-08-01,四端部署)**:最终形态不是独立的 bundleStatus"内容判定"维度(首版上线后即废弃——它依赖开关开启+双侧 hash+恰好有更新流动,观测窗口太窄、零数据),而是**请求侧合并进原生包面板**:客户端报了 bundleHash 就记 `packageVersion_bundleHash`(admin 图例把 64 位 hex 截 8 位展示),否则回退 `packageVersion_buildTime`,同一 attribute。全量流量可见、与开关/判定结果解耦;未注册人群在面板上直接带 packageVersion 上下文。代价是丢失 matched/rebuiltSameJs 区分——那只有教育用途,unknownBundle 占比(§4.6 的推进指标)不受影响。曾考虑挂在 `classifyCheckUpdateResult` 的 hitType 上,否决:hitType 是"下发了什么产物"维度,混入二进制身份维度不干净
248
+ - `digests` 下发 + `__diff.json` copies 附摘要——与客户端 Phase 3 的消费侧同波次做
249
+ - ~~CLI 上传冲突提示~~ **已实现(改判为无需动 CLI)**:`(appId, name)` 唯一约束下重复上传本来就 409,在两台服务器的冲突分支里用 bundleHash 增强语义——内容一致 →"无需重复上传"、内容不同 →"重打包且 JS 有变,请提升原生版本号"、任一侧无 hash → 通用文案不变。查询仅发生在冲突路径。cresc 3 条路由级用例覆盖;pushy 无路由级测试脚手架,靠同构代码 + cresc 用例 + tsc 兜底
250
+
251
+ 另:cresc-server 顺手修了一个与本迁移无关的存量 bug——`/email/confirm` 的 `select` 漏了 `extra`/`name`(`user.ts:888`),导致改邮箱时 OAuth 凭据吊销实际不生效(安全相关)+ 通知邮件用户名恒为兜底文案。
252
+
253
+ ### 4.6 推进门槛
254
+
255
+ Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖率**:判定矩阵第 4/5 行本来就规定了"没上报 bundleHash → 原样走 buildTime 启发式"。所以 Phase 2 可以在覆盖率很低时就上线,收益随客户端升级自然增长。
256
+
257
+ 真正要看的指标:`unknownBundle` 占比。它高说明教育有效(发现了真问题),持续不降说明 §7 的触达链路没起作用。
258
+
259
+ ---
260
+
261
+ ## 5. Phase 3 — 收尾(客户端)
262
+
263
+ > **定位修订(2026-08-03):buildTime 不删,永久保留作 fallback。**
264
+ > 理由:服务端分支与客户端双上报的保留成本≈0,而删除的伤害面(bundleHash
265
+ > 空串设备失去二进制身份 → 重打包检测失明 / 双缺请求拿不到 full)明确存在
266
+ > 且只能靠空串率数据压低,收益(构建注入清理、协议更干净)不急迫。注入的
267
+ > pnpm 老毛病若再发作,修注入本身而不是删身份。
268
+ > Phase 3 因此收缩为一件事:**§5.1 的 SyncBinaryVersion 迁移**——改为
269
+ > `bundleHash || buildTime`(bundleHash 优先、buildTime 兜底,schema 认领式
270
+ > 迁移照做)。不做它,rebuiltSameJs 的收益只兑现一半:服务端已不 block、照发
271
+ > pdiff,但客户端 buildTime 一变仍清状态回内置包再白下载一遍;hash 优先后
272
+ > 同 JS 重打包做到真正无感。§5.2 的 digests/copies 摘要照旧待做;§5.3 的
273
+ > 降级序列与"删构建注入"**取消**。
274
+
275
+ ### 5.1 `SyncBinaryVersion` 迁移有个状态危险,必须处理
276
+
277
+ 设计文档写"`SyncBinaryVersion` 改 `(packageVersion, bundleHash)`"。直接改会出事:
278
+
279
+ ```
280
+ 现状:sp 里存的是 build_time
281
+ 改后:代码拿 bundle_hash 去比 sp 里的 build_time
282
+ 结果:必然不等 → 判定"二进制变了" → 清空全部热更状态
283
+ → 所有设备的热更被抹掉一次,全网回内置包
284
+ ```
285
+
286
+ 这是一次性的全网退版,比本次要解决的问题严重得多。
287
+
288
+ **解法**:给持久化状态加一个 schema 版本号,迁移而不是比较。
289
+
290
+ ```
291
+ 读状态时:
292
+ 若 stateSchemaVersion < 2:
293
+ 不做 binary 变更判定(这一次跳过)
294
+ 把当前 bundleHash 写入,stateSchemaVersion = 2
295
+ 保持 currentVersion / firstTime 等原样
296
+ 否则:
297
+ 正常比较 (packageVersion, bundleHash)
298
+ ```
299
+
300
+ 即"首次遇到旧格式状态时,认领它而不是否决它"。这是纯函数逻辑,落在 `cpp/patch_core/state_core.cpp`,可以直接单测覆盖。
301
+
302
+ ### 5.2 其余收尾
303
+
304
+ - 下载产物 `digests` 校验(IO-6 本体)
305
+ - apply 时 copies 摘要校验 → 不符即 fallback full,**永不装出坏包**
306
+ - 删除 `pushy_build_time` 构建注入:podspec 的 script_phase 写 node_modules(pnpm 下的老问题)、gradle 的 resValue、Harmony 的 meta.json 一并消失
307
+
308
+ ### 5.3 buildTime 的降级顺序
309
+
310
+ 不要一步到位。`bundleHash` 为空时(计算失败、老二进制)仍需要一个身份标识,所以:
311
+
312
+ 1. 先让 `SyncBinaryVersion` 变成 `(packageVersion, bundleHash || buildTime)`
313
+ 2. 观察一个版本周期,确认 `bundleHash` 为空的比例足够低
314
+ 3. 再删构建期注入
315
+
316
+ ---
317
+
318
+ ## 6. 分阶段发布与回退
319
+
320
+ | Phase | 仓库 | 可独立发布 | 回退方式 |
321
+ |---|---|---|---|
322
+ | 0 | react-native-update | ✅ | 老服务端忽略未知字段,天然无害 |
323
+ | 1 | react-native-update-cli | ✅ | 同上 |
324
+ | 2 | pushy-server | ✅ | `dynamicConfig` 开关(§4.3) |
325
+ | 3 | react-native-update | ❌ 依赖 Phase 2 稳定 | 各项独立回退;§5.1 的 schema 版本号不可回退,需谨慎发布 |
326
+
327
+ 任意新老组合的行为 = 两侧能力交集,最差退化为现状。
328
+
329
+ ---
330
+
331
+ ## 7. 风险
332
+
333
+ | # | 风险 | 处置 |
334
+ |---|---|---|
335
+ | R1 | 客户端与 CLI 哈希了不同的文件 | §0:两侧都复用已有的 pdiff 源解析逻辑,不新写;验收里有手工比对 |
336
+ | R2 | 服务端缓存串响应 | §4.1,必须先改缓存键再上判定逻辑 |
337
+ | R3 | Phase 3 的状态迁移抹掉全网热更 | §5.1 的 schema 版本号;纯函数,单测覆盖 |
338
+ | R4 | ~~`librnupdate.so` 重编引入对齐/ABI 回归~~ | **Phase 0 已消除**:Android 改用 MessageDigest,不动 `.so`(§2.1)。Phase 3 重编时 CI 的 `verify-android-so.js` 兜 16KB 对齐 |
339
+ | R5 | 自定义 bundle 名 / Expo / use_frameworks 下客户端定位不到文件 | 定位不到就返回空串 → 退回 buildTime 启发式,不比现状差。自定义名三处(客户端哈希 / pdiff 源 / CLI 提取)一致硬编码,退化行为对齐(§0) |
340
+ | R6 | 首次安装后第一次 checkUpdate 拿不到哈希 | §2.5 的 200ms 超时 + 省略字段,下次自然带上 |
341
+ | R7 | "重打包但 JS 未变"行下发的 pdiff 因包内资源漂移而 apply 失败 | §4.2.1:apply 时校验 + fallback full + 遥测。这是本方案里唯一被下调保护强度的地方,用运行时保证换回主场景的收益 |
342
+
343
+ ---
344
+
345
+ ## 8. 建议的执行顺序
346
+
347
+ Phase 0 与 Phase 1 无依赖,**可并行**,且 Phase 1 只有几行、应当先合(先让平台开始积累 `bundleHash` 记录,Phase 2 上线时就有数据可判)。
348
+
349
+ ```
350
+ Phase 1 (CLI, 数行) ─┐
351
+ ├─> Phase 2 (服务端, 带开关) ─> 观察 ─> Phase 3 (收尾)
352
+ Phase 0 (客户端) ─┘
353
+ ```
354
+
355
+ **Phase 3 之前不要动 `NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉**——协议此时还在变。
@@ -0,0 +1,192 @@
1
+ # 原生检测更新设计:让砖机也能被修好
2
+
3
+ > 状态:设计草案,待评审
4
+ > 取代:`REMOTE_RESET_DESIGN.md`(本地启动熔断方案,已放弃,理由见 §1.2)
5
+ > 前置:`BUNDLEHASH_DESIGN.md`(设计定稿、待实施)——协议下沉前必须先定稿协议
6
+ > 关联:[[native-update-reset-design]] 的 Phase 3
7
+
8
+ ---
9
+
10
+ ## 1. 背景
11
+
12
+ ### 1.1 要解决的问题不变
13
+
14
+ `markSuccess` 之后设备陷入不可启动状态时,现有安全网(`first_time / first_time_ok`)已经拆除:`last_version` 被清空、目录被删。JS 跑不起来 → checkUpdate 发不出去 → 收不到任何补救。
15
+
16
+ ### 1.2 为什么放弃本地熔断
17
+
18
+ 上一版方案是"原生数连续未确认启动次数,达标就回退到内置包"。实施过程中连续挖出两个会**误 reset 好版本**的路径:
19
+
20
+ 1. **后台启动被计数** —— FCM 后台消息 / HeadlessJS / 静默推送都会创建 RN 实例并走到 `getBundleUrl`,但不挂 React 树,确认信号在结构上不可能到达
21
+ 2. **多 root component** —— 启动了不含 `UpdateProvider` 的那个入口;而且是静默的(该入口若未构造 `Pushy`,连契约错误都报不出来)
22
+
23
+ 两个洞的共同根因:**把"应用启动了"当成了"应用渲染了 UI"**,而 RN 的启动形态远比这多。既然两轮枚举都没穷尽,就不能假设第三轮能穷尽。
24
+
25
+ 更关键的是补救手段本身就弱:回退到内置包意味着丢掉全部热更、把用户打回可能很老的代码。
26
+
27
+ **而原生检测提供的补救严格更好**:滚到修好的版本。开发者甚至不需要发新版——在控制台把 package 重新绑回上一个正常版本即可,效果一样。
28
+
29
+ ### 1.3 放弃后的残留
30
+
31
+ 原生检测要联网。以下三种情况没有补救:离线设备、开发者尚未察觉的窗口期、服务端/CDN 故障。
32
+
33
+ **接受**。这三种下唯一可能的动作是"回退到内置包",而它的误伤面已经证明比收益大。
34
+
35
+ ---
36
+
37
+ ## 2. 目标与非目标
38
+
39
+ **目标**
40
+ - 热更版本坏到 JS 完全跑不起来时,**下次启动**仍能拉到修复版并装上
41
+ - 正常启动**不增加**任何网络往返(是搬迁 checkUpdate,不是新增)
42
+ - 正常启动**不阻塞**
43
+ - 检测逻辑三端一致,不写三遍
44
+
45
+ **非目标**
46
+ - 阻塞式启动更新(为救千分之一的设备惩罚全部用户)
47
+ - 离线自愈
48
+ - 保留任何形式的本地熔断
49
+
50
+ ---
51
+
52
+ ## 3. 总体时序
53
+
54
+ ```
55
+ 冷启动
56
+ 1. 原生解析状态 → 决定加载哪个版本(现有逻辑不变)
57
+ 2. 原生在后台线程发起 checkUpdate → 下载 → 应用 patch → setNeedUpdate
58
+ (全程不阻塞,本次启动照常加载现有版本)
59
+ 3. 原生加载 app bundle
60
+ ...
61
+ 下次冷启动
62
+ 1. 第 2 步的成果在这里生效
63
+ ```
64
+
65
+ **修复延迟一次启动。** 砖机场景下这完全够用——用户本来就会再点一次图标。换来的是零启动开销。
66
+
67
+ 关键性质:第 2 步**完全不依赖 app bundle**。app bundle 有语法错误、求值即崩、触发原生崩溃,都不影响它。
68
+
69
+ ---
70
+
71
+ ## 4. 核心决策:检测逻辑放在哪
72
+
73
+ 三个候选:
74
+
75
+ | 方案 | 三端一致 | 可远程更新 | 复杂度 / 风险 |
76
+ |---|---|---|---|
77
+ | A. 原生各写一遍 | ❌ 写三遍、漂移 | ❌ | 低但重复 |
78
+ | B. C++ 纯函数(`update_flow_core`) | ✅ | ❌ | 中;与 `patch_core`/`state_core` 同构,无新技术风险 |
79
+ | C. **Guardian bundle(JS 纯函数 + Hermes)** | ✅ | ✅ | 中高;见 §5 |
80
+
81
+ A 直接淘汰。B 是 [[native-update-reset-design]] 里 Phase 3 的原计划。C 是本文重点评估的新选项。
82
+
83
+ **B 与 C 的差别只有一个:检测逻辑本身出 bug 时,能不能不发新 binary 就修好。**
84
+
85
+ ---
86
+
87
+ ## 5. Guardian bundle
88
+
89
+ ### 5.1 关键设计约束:它必须是纯函数,不做 IO
90
+
91
+ 直觉上"独立 bundle 跑检测"意味着要给它一个能联网的 JS 环境——那就得注入 `fetch`、要 Promise、要微任务队列、要线程模型,等于自造一个小 RN。**不要这样做。**
92
+
93
+ 正确的边界是:**guardian 只做决策,IO 全部由原生执行。**
94
+
95
+ ```
96
+ 原生 ──> guardian.buildCheckRequest(state) ──> { url, headers, body }
97
+ 原生 <── (发 HTTP,各端现成客户端)
98
+ 原生 ──> guardian.decide(state, responseText) ──> { action, hash, url, type } | { action: 'none' }
99
+ 原生 <── (下载 + patch_core 应用 + state_core switchVersion,全是现成的)
100
+ 原生 ──> guardian.onOutcome(state, result) ──> { nextState }
101
+ ```
102
+
103
+ 这个边界带来的简化是决定性的:
104
+
105
+ - **不需要事件循环、不需要 Promise、不需要注入 HTTP** —— 只是同步求值一个小 HBC 再调几个函数
106
+ - **隔离性天然成立** —— guardian 与 app bundle 是两个独立的 parse 单元、独立求值;app bundle 的语法错误跟它毫无关系
107
+ - 灰度分桶(`isInRollout`)、diff→pdiff→full 选择、`expVersion` 解析、URL 拼接这些**已经是纯函数了**(`src/isInRollout.ts`、`src/resolveCheckResult.ts`),可以几乎原样搬过去
108
+
109
+ ### 5.2 运行时
110
+
111
+ 在后台线程创建一个 `hermes::makeHermesRuntime()`,求值 guardian 的 HBC,调用导出的函数,用完销毁。全同步,毫秒级。
112
+
113
+ 放在 `cpp/` 里与 `patch_core` / `state_core` 同级,三端共享同一份 C++;各端只提供 HTTP 客户端(Android OkHttp、iOS NSURLSession、Harmony `@ohos.net.http` —— 都已在用)和文件路径。
114
+
115
+ **主要风险**:直接链接 libhermes 的 C++ API 在 RN 各版本间会变,可能出现符号/ABI 兼容问题。Android 的 `librnupdate.so` 是预编译产物(4 个 ABI),链上 Hermes 后与宿主 RN 的 Hermes 版本耦合。**这是 C 相对 B 的主要代价,需要先做一个链接可行性验证再决定**。
116
+
117
+ 备选:不用 Hermes,用一个极小的嵌入式 JS 引擎(QuickJS 约 200KB)。彻底解耦宿主 RN,代价是包体增加与另一套字节码工具链。
118
+
119
+ ### 5.3 分发与覆盖
120
+
121
+ | | 来源 | 作用 |
122
+ |---|---|---|
123
+ | 基线 | 随 binary 打包(asset / rawfile) | 永远存在,已随发版验证过 |
124
+ | 覆盖 | 服务端下发(独立 hash,独立通道) | 修 guardian 自身的 bug |
125
+
126
+ 覆盖包的下载复用现有下载器;存放路径与热更版本目录隔离。
127
+
128
+ ### 5.4 Guardian 自身的回滚保护
129
+
130
+ 服务端下发的 guardian 跑在所有东西之前,它坏了就是真的砖。所以需要一层它自己的首次运行保护——**而这次这层保护是可靠的,因为 guardian 的成功判据是明确的**:
131
+
132
+ ```
133
+ 新 guardian 首次使用:
134
+ 置 guardianFirstRun = true 并落盘
135
+ 求值 + 调用 decide()
136
+ 正常返回 → 清除标记,采纳
137
+ 抛异常 / 求值失败 / 进程崩溃 → 下次启动看到标记仍在 → 弃用,回落基线 guardian
138
+ ```
139
+
140
+ 这不是本地熔断那种"猜应用是否健康"——是"这个纯函数调用有没有正常返回",二值、无歧义、不存在误判空间。这正是本地熔断做不到而这里能做到的原因:**判据从"应用是否可用"(不可判定)变成了"函数是否返回"(可判定)**。
141
+
142
+ ### 5.5 安全
143
+
144
+ 服务端可下发一段在启动最早期执行、且有权决定装什么版本的代码。这不是新增的风险类别——现有热更通道本来就能推送任意 JS——但 guardian 通道必须享有**同等**的保护(HTTPS、hash 校验、下发权限与审计)。不能因为它"小"就走简化路径。
145
+
146
+ ---
147
+
148
+ ## 6. 与 app JS 的分工
149
+
150
+ app 侧的 `client.ts` / `UpdateProvider` 仍然负责**交互**:更新提示、下载进度、`beforeReload` 等钩子、遥测。
151
+
152
+ 检测本身不要跑两遍。收敛方式:
153
+
154
+ - guardian 完成一次检查后把结果(含 `checkUpdate` 原始响应与时间戳)落盘
155
+ - JS 侧 `checkUpdate` 先读这份结果,在有效期内直接复用,不再发请求
156
+ - 需要用户确认的策略(`alertUpdateAndIgnoreError` 等)下,guardian 只**下载**不 `switchVersion`,把激活留给 JS
157
+
158
+ `updateStrategy: 'silentAndLater'` 语义下则 guardian 可以直接走完全程。
159
+
160
+ ---
161
+
162
+ ## 7. 风险
163
+
164
+ | # | 风险 | 处置 |
165
+ |---|---|---|
166
+ | R1 | Hermes C++ API / ABI 与宿主 RN 版本耦合 | **先做链接可行性验证再定 B/C**;备选 QuickJS |
167
+ | R2 | 更新链路整个搬到每次冷启动的原生路径,风险集中——那里出 bug 砖的是所有设备,JS 插不上手 | guardian 可远程更新正是对这条的缓解(这是选 C 的核心理由);配 §5.4 的回滚保护 |
168
+ | R3 | 协议若在下沉后再变要返工 | 前置 bundleHash 迁移,见 §8 |
169
+ | R4 | guardian 与 app JS 双重检查造成流量翻倍 | §6 的结果复用 |
170
+ | R5 | 后台线程在启动早期发请求,与冷启动争带宽 | 延迟若干秒再发;本来就是"下次启动生效",不急 |
171
+ | R6 | 服务端下发 guardian 的权限被滥用 | §5.5 |
172
+
173
+ ---
174
+
175
+ ## 8. 分期
176
+
177
+ 1. **bundleHash 迁移**(`BUNDLEHASH_DESIGN.md`,已定稿)—— 协议下沉的前置,否则返工
178
+ 2. **纯函数抽取** —— 把 `buildCheckRequest` / `decide` 从 `client.ts` 剥成无 IO 的纯函数,先在 JS 侧原地使用并补测试。这一步 B / C 都需要,且不依赖运行时选型,**可以立刻开始**
179
+ 3. **运行时选型** —— Hermes 链接可行性验证;失败则退回方案 B(C++ 纯函数)
180
+ 4. **原生编排** —— 三端 HTTP + 调用纯函数 + 复用现有下载/patch/state
181
+ 5. **guardian 分发通道**(仅方案 C)—— 打包、下发、§5.4 回滚保护
182
+
183
+ 第 2 步是关键:**它是 B 和 C 的公共前置**,做完之后再决定选型也不迟,而且它本身就能让现有 JS 实现更可测。
184
+
185
+ ## 9. 已写代码的处置
186
+
187
+ `REMOTE_RESET_DESIGN.md` 那一版的本地熔断已经三端实现完毕(未提交)。按 §1.2 应当整体撤回。可留用的只有:
188
+
189
+ - `blockedVersions` —— 与本方案无关,可独立保留或一并撤回
190
+ - `autoReset` 遥测事件 —— 无 reset 动作后失去意义,撤回
191
+
192
+ `unconfirmedBoots` / 前台闸门 / `markBootHealthy` / `PROVIDER_REQUIRED` / `CONTENT_APPEARED` 全部撤回。
@@ -21,6 +21,7 @@ Hdp_Files := \
21
21
 
22
22
  LOCAL_SRC_FILES := \
23
23
  ../../cpp/patch_core/archive_patch_core.cpp \
24
+ ../../cpp/patch_core/digest.cpp \
24
25
  ../../cpp/patch_core/hbc_transform.cpp \
25
26
  ../../cpp/patch_core/hbc_transform_wire.cpp \
26
27
  ../../cpp/patch_core/patch_core.cpp \
Binary file
Binary file
@@ -96,6 +96,13 @@ final class BundledResourceCopier {
96
96
  // failures (disk full, corrupt archive) that must fail the update,
97
97
  // not skips — otherwise the update activates with missing resources.
98
98
  ArrayList<String> failedCopies = new ArrayList<String>();
99
+ // Entries whose manifest-declared CRC32 could not be satisfied by
100
+ // any installed archive: the path-matched file has different bytes
101
+ // and no entry anywhere carries the expected content. Copying would
102
+ // install a wrong resource (rebuilt binary with drifted assets), so
103
+ // these fail the whole patch — the JS strategy chain then falls
104
+ // back to the full package.
105
+ ArrayList<String> crcMismatches = new ArrayList<String>();
99
106
 
100
107
  for (String fromPath : new ArrayList<String>(remainingFiles.keySet())) {
101
108
  ArrayList<File> targets = remainingFiles.get(fromPath);
@@ -107,6 +114,7 @@ final class BundledResourceCopier {
107
114
  String actualSourcePath = fromPath;
108
115
  SafeZipFile matchedZipFile = null;
109
116
  ResolvedResourceSource resolvedResource = null;
117
+ Long wantedCrc = crcByFrom != null ? crcByFrom.get(fromPath) : null;
110
118
 
111
119
  if (entry == null) {
112
120
  String normalizedFrom = normalizeResPath(fromPath);
@@ -117,21 +125,39 @@ final class BundledResourceCopier {
117
125
  }
118
126
  }
119
127
 
128
+ // Verify a path-matched entry against the manifest-declared
129
+ // CRC32: a rebuilt binary can keep the path but drift the
130
+ // content. On mismatch, discard the path match and try to
131
+ // locate the expected content elsewhere. An entry with an
132
+ // unknown CRC (-1) cannot be verified and is accepted as-is.
133
+ if (entry != null && wantedCrc != null
134
+ && entry.getCrc() != -1L && entry.getCrc() != wantedCrc.longValue()) {
135
+ entry = null;
136
+ actualSourcePath = fromPath;
137
+ }
138
+
120
139
  // Content (CRC32) match: robust across APK/AAB packaging because
121
140
  // the checksum is over the uncompressed file content, not its
122
141
  // path. Preferred over the resource-id heuristic below.
123
- if (entry == null && crcByFrom != null) {
124
- Long wantedCrc = crcByFrom.get(fromPath);
125
- if (wantedCrc != null) {
126
- ZipSource matched = crcToEntry.get(wantedCrc);
127
- if (matched != null) {
128
- entry = matched.entry;
129
- matchedZipFile = matched.zipFile;
130
- actualSourcePath = matched.entry.getName();
131
- }
142
+ if (entry == null && wantedCrc != null) {
143
+ ZipSource matched = crcToEntry.get(wantedCrc);
144
+ if (matched != null) {
145
+ entry = matched.entry;
146
+ matchedZipFile = matched.zipFile;
147
+ actualSourcePath = matched.entry.getName();
132
148
  }
133
149
  }
134
150
 
151
+ // A declared CRC that no installed entry satisfies is a hard
152
+ // content mismatch. The resource-id heuristic below cannot be
153
+ // trusted here: the content index above already covers every
154
+ // entry, so anything the heuristic finds has the wrong bytes.
155
+ if (entry == null && wantedCrc != null) {
156
+ crcMismatches.add(fromPath);
157
+ remainingFiles.remove(fromPath);
158
+ continue;
159
+ }
160
+
135
161
  if (entry == null) {
136
162
  resolvedResource = resolveBundledResource(fromPath);
137
163
  if (resolvedResource != null) {
@@ -189,6 +215,13 @@ final class BundledResourceCopier {
189
215
  remainingFiles.remove(fromPath);
190
216
  }
191
217
 
218
+ if (!crcMismatches.isEmpty()) {
219
+ throw new IOException(
220
+ "Bundled resource content mismatch (crc32) for "
221
+ + crcMismatches.size() + " entries: " + crcMismatches
222
+ );
223
+ }
224
+
192
225
  if (!failedCopies.isEmpty()) {
193
226
  throw new IOException(
194
227
  "Failed to copy " + failedCopies.size()