feat(step097): preserve B fixes for unified V15 source

This commit is contained in:
tei
2026-09-27 08:41:27 +09:00
parent b1239ac3b1
commit f3a44c7d5f
18 changed files with 711 additions and 36 deletions

View File

@@ -17,9 +17,9 @@
| 05 B13 | `sameFieldGpuContent()` 保持 revision/version 无关的 payload 相等;renderer GPU 槽位更新使用严格 `sameFieldGpuEndpoint()`;Source mix-only cache 使用 projection identity、binding、immutable frame 和 plane/validity storage pointer identity | mix-only 复用同一 T0/T1 snapshot;frame/revision、grid、validity、U/V、encoding 或 binding 改变则重物化并失效;invalid/clear fail-closed | `field_data_adapter.hpp`、projection materialization、RasterArraySource 与 renderer;Source cache 测试 + `ParticleGpuField.*` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,A2 定向各 3/3 PASS;FZG1 stable mix 时 upload/reset 计数稳定;clear/reload 后合法增加 | | 05 B13 | `sameFieldGpuContent()` 保持 revision/version 无关的 payload 相等;renderer GPU 槽位更新使用严格 `sameFieldGpuEndpoint()`;Source mix-only cache 使用 projection identity、binding、immutable frame 和 plane/validity storage pointer identity | mix-only 复用同一 T0/T1 snapshot;frame/revision、grid、validity、U/V、encoding 或 binding 改变则重物化并失效;invalid/clear fail-closed | `field_data_adapter.hpp`、projection materialization、RasterArraySource 与 renderer;Source cache 测试 + `ParticleGpuField.*` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,A2 定向各 3/3 PASS;FZG1 stable mix 时 upload/reset 计数稳定;clear/reload 后合法增加 |
| 06 B07 | 新增内部 signed RGBA8 编码,byte 128 明确表示零;GL/Metal shader 以 high precision 还原 byte | 不改公开 QBIN/FieldProfile encoding;量化误差与 sub-half-quantum 微速边界明确测试 | `particle_gpu_math.hpp`、GL/Metal shader 源及生成头、`particle_gpu_math.test.cpp` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,含 signed zero、取消、NaN/invalid 与微速边界;两次 shader generator 幂等;无 iOS runtime | | 06 B07 | 新增内部 signed RGBA8 编码,byte 128 明确表示零;GL/Metal shader 以 high precision 还原 byte | 不改公开 QBIN/FieldProfile encoding;量化误差与 sub-half-quantum 微速边界明确测试 | `particle_gpu_math.hpp`、GL/Metal shader 源及生成头、`particle_gpu_math.test.cpp` | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,含 signed zero、取消、NaN/invalid 与微速边界;两次 shader generator 幂等;无 iOS runtime |
| 07 B08 | 统一 helper 与 GL/Metal 粒子位移路径都为 Mercator 东向 U、北向 V 应用相同 cos(latitude) metric,并夹紧极区分母 | 保持 U/V canonical m/s、V 北向为正、既有视觉时间倍率 | `particle_gpu_math.hpp`、renderer 与 GL/Metal shader 源及生成头;0°/45°/60° 四方向和极区测试 | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,含数学定向测试;Android FZG1 场景闭环通过;无 iOS runtime | | 07 B08 | 统一 helper 与 GL/Metal 粒子位移路径都为 Mercator 东向 U、北向 V 应用相同 cos(latitude) metric,并夹紧极区分母 | 保持 U/V canonical m/s、V 北向为正、既有视觉时间倍率 | `particle_gpu_math.hpp`、renderer 与 GL/Metal shader 源及生成头;0°/45°/60° 四方向和极区测试 | CODE_IMPLEMENTED_STATIC_CHECKED / A2 源码提交 `f49ffa68a` / broad OpenGL 与 Metal host 各 121/121 PASS,含数学定向测试;Android FZG1 场景闭环通过;无 iOS runtime |
| 08 N05 | `render_segment_json.cpp` C 字符串解析 | invalid 清旧计划 | JSON/parser;NUL、重复键、恢复 | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core、桥接 | | 08 N05 | 长度感知 JSON 解析,拒绝 NUL/重复键/非法 schema,invalid fail-closed | 清旧计划、合法恢复 | `render_segment_json.hpp`、双平台 bridge 与 GTest | CODE_IMPLEMENTED / B Core 定向 6/6、扩展 139/139 PASS;待 B Git,iOS runtime 未测 |
| 09 N06 | `raster_array_source.cpp` 入库早于物化数值校验 | validity=0/noData 合法 | source/store;好旧帧→坏高 revision | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core | | 09 N06 | FieldProfileValidator 在 Store commit 前检验有效数值及 scale/offset | validity=0/noData 合法;坏高 revision 不覆盖好帧 | FieldProfileValidator/Source GTest,修正旧 16-bit fixture | CODE_IMPLEMENTED / B Core 定向 6/6、扩展 139/139 PASS;待 B Git |
| 10 N07 | Android attach map listener 重复注册 | detach/destroy 可重入 | Android bridge;100 次 attach/detach | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Android | | 10 N07 | View owner、generation guard、map destroy observer 源码已写 | 旧 map 回调不得影响新世代,监听须释放 | Kotlin/Java JUnit,真实 MapLibreMap.onDestroy | Android Native JUnit 2/2、Java 编译与 owner 隔离 JVM JUnit 4/4 PASS;BLOCKED_NATIVE_AAR_COMPATIBILITY:正式 `.13` 缺新 Map 方法,默认 RN consumer 不可配对编译,B 不判 PASS |
| 11 F01/C01 | `renderer_impl.cpp:505` full diagnostics 快照 | 外部 pull schema 不变 | runtime/renderer;快照计数 | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core | | 11 F01/C01 | `renderer_impl.cpp:505` full diagnostics 快照 | 外部 pull schema 不变 | runtime/renderer;快照计数 | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core |
| 12 F02/C02/B03 | Metal offscreen `swap()` 同步等待 | 不删不明依赖 wait | Metal;GPU capture | DEFERRED_DESIGN / 无 / Metal | | 12 F02/C02/B03 | Metal offscreen `swap()` 同步等待 | 不删不明依赖 wait | Metal;GPU capture | DEFERRED_DESIGN / 无 / Metal |
| 13 F03/C03/B04 | cache revision 变化释放 texture | target clear 必须先正确 | cache;同尺寸重绘/resize | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / GL、Metal | | 13 F03/C03/B04 | cache revision 变化释放 texture | target clear 必须先正确 | cache;同尺寸重绘/resize | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / GL、Metal |
@@ -29,7 +29,7 @@
| 17 C06/B05/B10 | A2 已处理相同 immutable projection 的 mix-only 重物化;其余 diagnostics pull、ordinal 与 UI owner 世代发布仍属后续范围 | A2 mix-only 不再复制/比较整张浮点快照;不据此宣称 C 阶段整体完成 | A2 部分:RasterArraySource cache 定向测试及 FZG1;C 部分仍按原任务另行实施 | A2 mix-only 子项已验证 / 当前工作树 `5bb1a0e` / OpenGL、Metal host 定向 3/3 PASS;C 其余项目未开始 | | 17 C06/B05/B10 | A2 已处理相同 immutable projection 的 mix-only 重物化;其余 diagnostics pull、ordinal 与 UI owner 世代发布仍属后续范围 | A2 mix-only 不再复制/比较整张浮点快照;不据此宣称 C 阶段整体完成 | A2 部分:RasterArraySource cache 定向测试及 FZG1;C 部分仍按原任务另行实施 | A2 mix-only 子项已验证 / 当前工作树 `5bb1a0e` / OpenGL、Metal host 定向 3/3 PASS;C 其余项目未开始 |
| 18 C07 | drawable/tweaker tick 重建、端点复制 | 不保留 stale GPU 引用 | Particle renderer;tick/resize | DEFERRED_DESIGN / 无 / GL、Metal | | 18 C07 | drawable/tweaker tick 重建、端点复制 | 不保留 stale GPU 引用 | Particle renderer;tick/resize | DEFERRED_DESIGN / 无 / GL、Metal |
| 19 F09/O01 | `runtime_state.hpp:230` 新 topology;旧 cache 析构按 index 计数 | 总量与当前 topology 分离 | runtime/cache 按 revision 过滤;新增 `OldCacheReleaseCannotChargeReplacementAtSameIndex` GTest | CODE_IMPLEMENTED_STATIC_CHECKED / `e67e4961b` / 2026-09-26 Core GTest 1/1 PASS,诊断组 5/5 PASS;GPU 资源验收仍待测 | | 19 F09/O01 | `runtime_state.hpp:230` 新 topology;旧 cache 析构按 index 计数 | 总量与当前 topology 分离 | runtime/cache 按 revision 过滤;新增 `OldCacheReleaseCannotChargeReplacementAtSameIndex` GTest | CODE_IMPLEMENTED_STATIC_CHECKED / `e67e4961b` / 2026-09-26 Core GTest 1/1 PASS,诊断组 5/5 PASS;GPU 资源验收仍待测 |
| 20 F05/E01 | Host package `.10`、Native plugin `.13` | 精确同版,勿动发布产物 | Host package/lock;默认 install/typecheck | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / RN、Android | | 20 F05/E01 | Test Host package/lock 已对齐正式 `.13`,SHA-256/SRI 核对一致 | RN/Native 必须精确同版,不改 `.13` 产物 | package/lock、release manifest、默认 RN 编译 | STATIC_VERSION_LOCK_PASS;B10 新 Native method 不在正式 `.13`,默认消费者仍阻塞 |
| 21 F06 | `renderer_impl.cpp` multi-C 走 full CPU | 保留安全 fallback | 后续 A/B 设计 | DEFERRED_DESIGN / 无 / GL、Metal | | 21 F06 | `renderer_impl.cpp` multi-C 走 full CPU | 保留安全 fallback | 后续 A/B 设计 | DEFERRED_DESIGN / 无 / GL、Metal |
| 22 F08 | source/tile 等全局失效 | 无依赖图前不缩窄 | 后续依赖设计 | DEFERRED_DESIGN / 无 / Core | | 22 F08 | source/tile 等全局失效 | 无依赖图前不缩窄 | 后续依赖设计 | DEFERRED_DESIGN / 无 / Core |
| 23 F10/E02 | Integration status/compatibility 仍写 `.5` | 发布≠NavSea 支持 | 负责人协调证据请求 | DEFERRED_DESIGN / 无 / 跨项目 | | 23 F10/E02 | Integration status/compatibility 仍写 `.5` | 发布≠NavSea 支持 | 负责人协调证据请求 | DEFERRED_DESIGN / 无 / 跨项目 |
@@ -39,7 +39,7 @@
- A1:跨 target 清除 CPU mask 为每次目标切换一次 O(1) 状态清理;当前同 tiles 可能零次重绘但产生错误像素。Metal clear 每个 pass 最多各一次附件 load 设置。异常后额外一次 full fallback,避免错误缓存跨帧复用。 - A1:跨 target 清除 CPU mask 为每次目标切换一次 O(1) 状态清理;当前同 tiles 可能零次重绘但产生错误像素。Metal clear 每个 pass 最多各一次附件 load 设置。异常后额外一次 full fallback,避免错误缓存跨帧复用。
- A2:PLAY 由唯一 Map/Source clock 每次 update 推进一次;V4 mix-only 的投影重物化计数为 0(50 次 Source tick 定向测试),decoded endpoint 变化时 color/particle projection 各物化一次。FZG1 日志在单次 stable mix-only epoch 内 upload/reset 计数不变;clear/reload 会合法增加计数。以上是源码测试和该次设备记录,不构成普遍 GPU 性能结论。 - A2:PLAY 由唯一 Map/Source clock 每次 update 推进一次;V4 mix-only 的投影重物化计数为 0(50 次 Source tick 定向测试),decoded endpoint 变化时 color/particle projection 各物化一次。FZG1 日志在单次 stable mix-only epoch 内 upload/reset 计数不变;clear/reload 会合法增加计数。以上是源码测试和该次设备记录,不构成普遍 GPU 性能结论。
- B:预期 JSON 校验 O(输入长度),事务校验 O(有效 cell 数),attach 重复次数不再增加 listener 数。尚未实施。 - B:JSON 严格校验 O(输入字节长度),有效数值验证 O(有效 cell × plane 数),View/map 每世代各至多一活跃监听;源码已实施,但正式 .13 Native ABI 不含新方法。无 GPU 性能实测。
- C:预期 full diagnostics 深复制从每 render 次降到每外部 pull 次;ordinal 前缀从每段 O(G) 降到 topology 更新 O(G);同时间物化从 O(cell) 降为 O(1)。尚未实施。 - C:预期 full diagnostics 深复制从每 render 次降到每外部 pull 次;ordinal 前缀从每段 O(G) 降到 topology 更新 O(G);同时间物化从 O(cell) 降为 O(1)。尚未实施。
## 2026-09-26 第 19 项实测更新 ## 2026-09-26 第 19 项实测更新
@@ -70,3 +70,7 @@
- 标准 Android Validation APK 为 OpenGL Debug/x86_64,61,753,803 bytes,SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`;安装到 FZG1-2 后设备 APK hash 相同。V4 Golden Wind test-only T0/T1 场景 17 张 PNG、24 个步骤、runner exit 0、ANR=0;PLAY 30 秒 UI 可响应,PAUSE 稳定,clear/reload 后再次 PLAY/PAUSE。 - 标准 Android Validation APK 为 OpenGL Debug/x86_64,61,753,803 bytes,SHA-256 `27bf4a6cc191bd86c4d1c10398478d81c2999edd61fe7242e40e5188516b0ca5`;安装到 FZG1-2 后设备 APK hash 相同。V4 Golden Wind test-only T0/T1 场景 17 张 PNG、24 个步骤、runner exit 0、ANR=0;PLAY 30 秒 UI 可响应,PAUSE 稳定,clear/reload 后再次 PLAY/PAUSE。
- 日志按资源窗口分别记录:clear/reload 前 mix-only epoch `fieldUploadCount=6/resetCount=2` 稳定;explicit clear/reload 后计数合法增加,达到 `fieldUploadCount=10/resetCount=4`,此后 replay 稳定。不可把 clear/reload 期间的上传与 reset 写成零。 - 日志按资源窗口分别记录:clear/reload 前 mix-only epoch `fieldUploadCount=6/resetCount=2` 稳定;explicit clear/reload 后计数合法增加,达到 `fieldUploadCount=10/resetCount=4`,此后 replay 稳定。不可把 clear/reload 期间的上传与 reset 写成零。
- build source diff SHA-256 `18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2` 与忽略目录中的 APK source snapshot 完全一致。未运行 iOS device runtime,按用户豁免记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`;不得称 Metal host GTest 为 iOS PASS。A2 Android/Core 完成,B/C 未开始,整个 STEP-097 仍 PARTIAL。 - build source diff SHA-256 `18927a5f30a88f5c1c3a00e0017473dc33b04151c227e4baf52250dc4ad2e6a2` 与忽略目录中的 APK source snapshot 完全一致。未运行 iOS device runtime,按用户豁免记 `USER_ACCEPTED_WITHOUT_IOS_RUNTIME_TEST`;不得称 Metal host GTest 为 iOS PASS。A2 Android/Core 完成,B/C 未开始,整个 STEP-097 仍 PARTIAL。
## 2026-09-26 B 独立验证及阻塞
- RDC Core 定向 4/4、修正旧 16-bit fixture 后 broad 134/134 PASS;正式 .13 Android SDK 缺 B10 新 map destroy observer,默认 RN consumer 无法与该新源码配对。B PARTIAL,C 不启动;详见 B 独立报告。

View File

@@ -61,3 +61,21 @@
### 2026-09-26 A2 源码独立 scoped commit 核验 ### 2026-09-26 A2 源码独立 scoped commit 核验
- A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。 - A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。
## 2026-09-26 B 阶段源码、Core 复核与 Native 版本阻塞(未收口)
- Codex 两次自动任务(Android 第 10 项销毁监听、旧数值夹具纠正)均使用 `codex exec --json` + GPT-6 Luna High,均有 `turn.completed` 和 exit 0,JSONL/status.json 已持久化于 `artifacts/maplibre-particle/step097-b10-codex/` 与 `step097-b-fixture-codex/`。
- 08 N05:长度感知 JSON/NUL/重复键/非法配置清除与恢复测试入口已实施;09 N06:提交前有限数值验证和旧有效修订保护已实施;10 N07:View owner、世代隔离与新 Map 销毁 observer 测试源码已实施;20 E01:Host package/lock 均指向正式 .13,正式 tarball SHA-256 与 SRI 已核对。
- RDC Mac Core 构建 PASS,四项 B 定向 GTest 4/4 PASS;broad 初次 132/134,真实原因是旧四单元 16-bit 测试借用了 4-byte UInt8 storage;仅修为合法 8-byte little-endian fixture 后复编与扩大回归 **134/134 PASS**,未放宽生产校验。
- 硬阻塞:B10 新 `MapLibreMap.addOnDestroyListener()` 不存在于已发布 .13 source commit `ddf20dec9`,现有 RN Gradle 默认正式 .13 AAR 无法提供该符号;本地新源码 Android 测试不能替代正式 RN consumer 精确配对构建。禁止借此修改 .13 发行产物。
- Linux Android 采用远端 `b1239ac3b` 新独立 worktree,并从 Mac 传入 B scoped patch/测试,原 Linux dirty 工作区未动。首次 Gradle 仅因工作树缺少 ANDROID_HOME 失败,按既有 SDK 路径重试;结果见 B 专项报告的后续记录。
- 当前 B = PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY,尚未 scoped commit/push;C 与 STEP-098 均不启动,iOS runtime 保持用户豁免。详见 `docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md`。
### 2026-09-26 B 阶段 Mac RDC 最新核验(仍阻塞)
- B10 `codex exec --json` 终态 `turn.completed`、exit 0,完整 JSONL/status.json 持久化;旧 16-bit fixture 纠正任务亦同。RDC 复编后 B 定向 6/6、扩展 139/139 PASS。
- Android `MapLibreMapDestroyListenerTest` 2/2 PASS,Native Java 编译 exit 0;隔离 Kotlin owner 4/4 PASS,均非默认 RN consumer 集成验收。日志和 XML 见 `artifacts/maplibre-particle/step097-b-rdc/`。
- .13 release source 无新 `MapLibreMap.addOnDestroyListener()`,新 RN Kotlin 源码默认依赖 .13 AAR;B 保持 `PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY`。Linux 隔离 Gradle 仍待最终状态;未启动 C、STEP-098,也未修改 .13 发布资产。
### 2026-09-26 B scoped Git 阻塞检查点
- Mac RDC 在独立 worktree 对 21 个 B 文件逐项 SHA-256 一致检查后,提交并推送 `step097-b-blocked-20260926`:`e463ba04ce956a54eed02b77f981eeeedd9a5534`,远端回读一致;`particle/main` 仍为 `b1239ac3b`,原 dirty/untracked(含 STEP-098)未动。
- 该提交只冻结 PARTIAL/BLOCKED 的源码及证据,不是 B PASS;C 与 STEP-098 仍禁止启动,.13 发布资产不变。

View File

@@ -115,3 +115,16 @@ build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.Ras
### 2026-09-26 A2 源码独立 scoped commit 核验 ### 2026-09-26 A2 源码独立 scoped commit 核验
- A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。 - A2 的 26 个生产源码、shader、TestApp-only V4 Golden fixture 和定向测试文件已固定为 `f49ffa68a17e0a505199bdbeccf07f8543f3a05f` 并推送,`origin/particle/main` 回读一致;没有 stage 用户未跟踪 STEP-098。完整代码/设备证据仍见 `docs/报告/STEP-097_A2_阶段验证记录_v3.md`,不因 Git 提交改变原 APK 的 `git_dirty=true` 来源事实。STEP-097 整体 PARTIAL,B/C 待用户指令。
## 2026-09-26 B 阶段 RDC 实测与待闭合
- Mac Core 增量构建 PASS;B 的 JSON 严格校验、非法配置清除与恢复、数值溢出拒绝和合法 validity=0 四项 GTest:**4/4 PASS**。
- 扩大回归初次 132/134,旧 16-bit fixture 误用 4-byte UInt8 storage;修正为合法四样本 8-byte 小端之后重新编译,broad **134/134 PASS**,没有放宽生产校验。
- Test Host package.json/package-lock.json 的 RN tarball 均为正式 `13.4.1-particle.13`,实包 SHA-256/SRI 一致。
- Android 要分别验证本仓新源码的 MapLibreMap destroy listener JUnit 与 RN module 对正式 `.13` AAR 的 compile;前者不能替代后者。正式 .13 source commit 无 `addOnDestroyListener`,因此当前 bridge 存在 ABI 阻塞,不能标 Android consumer PASS。
- C、STEP-098、Jest 与正式发布不执行;完整证据见 `docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md`。
## 2026-09-26 B 阶段 RDC 验证更新
- Mac 最新 GTest runner:定向 6/6、扩展 139/139 PASS,旧 broad 2 项失败经纠正测试 fixture 后消除;完整日志 `artifacts/maplibre-particle/step097-b-rdc/validation-*.log`。
- Android Native OpenGL Debug:`MapLibreMapDestroyListenerTest` 2/2 PASS;`compileOpenglDebugJavaWithJavac` exit 0;owner Kotlin 独立 JVM JUnit 4/4 PASS(源码 SHA 与当前 B10 一致)。隔离单测不能替代 React Native 模块与发布 AAR 精确配对编译。
- B10 当前默认 RN Gradle 对已发布 .13 Native AAR 依赖新缺失符号 `addOnDestroyListener`;RN consumer 校验标 `BLOCKED_NATIVE_AAR_COMPATIBILITY`,禁止记录 PASS 或覆盖 .13。Linux 独立 Gradle 状态另记,C 与 STEP-098 均不得越过此门槛。

View File

@@ -0,0 +1,36 @@
# STEP-097 B 阶段独立验收与版本阻塞报告 v1
状态:**PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY**。B 不判 PASS,C 不启动。本报告按实际验证结果更新,不以 Codex 终态代替验收。
## 基线与执行
- 日期:2026-09-26;Mac 仓库 `particle/main` HEAD `b1239ac3b1554943200e6fbf14321459ccc86527`;实施工作树 dirty,尚未 commit/push。
- Integration `main` 已由 RDC fetch 核验,本地及远端均为 `318f8ccf911ac3136c3086a1d14e61f83e2d4bce`;Contract C-001 未改。
- Codex 两次均使用 GPT-6 Luna High,`codex exec --json`;分别收到 `turn.completed`、退出码 0;完整 JSONL/status.json 位于 `artifacts/maplibre-particle/step097-b10-codex/` 和 `step097-b-fixture-codex/`。
- 工作范围:08 JSON、09 数值 ingress、10 Android owner/map destroy、20 Test Host 版本配对;用户未跟踪 STEP-098 及正式 .13 发布资产未触碰。
## 代码和独立证据
- 08:按真实字节长度解析,拒绝 NUL、重复键、非法 strategy/schema,失败清旧计划;已存在定向 GTest。
- 09:有效 cell 数值及 scale/offset 有限性验证在提交 Store 前执行;合法 validity=0 保留,坏高 revision 不覆盖旧场。
- 10:新 View attachment owner、generation guard 与 map destroy observer;附 Android owner/JUnit 和 MapLibreMap 真正 onDestroy 测试。
- 20:Test Host package.json 与 lock 均精确为 RN `13.4.1-particle.13`;正式 tarball SHA-256 `3a726db06cb967fe56de77ca4a72feed98cd642a2d1b73fd51b1531d15ef7539` 与 release manifest 一致,lock integrity 的 SHA-512 与实包一致。
- Mac Core 增量构建 PASS;B 四项定向 GTest **4/4 PASS**;原 broad 134 项中 2 项旧 UInt8→Int16/UInt16 4-byte fixture 不合法,修正为合法 8-byte 小端四样本后重新构建,broad **134/134 PASS**。未放宽生产数值校验或既有断言。
- 原始日志:`artifacts/maplibre-particle/step097-b-rdc/core-*.log`。
## 阻塞门槛
- B10 新增 `MapLibreMap.addOnDestroyListener()` 为 Android SDK 新方法;正式 .13 release source commit `ddf20dec9fe9cd05b77274827b6825829500dd5a` **没有此方法**。
- Test Host 默认及 RN 模块 Gradle 仍消费已发布原生 AAR `13.4.1-particle.13`。所以新 RN Kotlin bridge 直接调用该方法时,现有默认正式消费组合不具备此符号;不能用本工作树 Native 源码 JUnit 冒充 .13 RN consumer 编译通过。
- 需要选择不增加已发布 ABI 依赖的完整生命周期方案,或者另走正式新 Native/RN 同版发行与配对验证。未经上游决定不得改写 .13 tag、manifest/AAR,也不得以反射或私有绕道掩盖版本问题。
## 2026-09-26 RDC 补充独立实测(Mac,覆盖上方旧测试数字)
- B10 Codex 的持久化 `status.json`:`gpt-6-luna/high`,75 条 JSONL 事件,`turn.completed`,exit 0;旧 fixture 修正任务同样 `turn.completed` / exit 0。两次交付本身不代表验收。
- 本轮 Mac 原生测试 runner 增量编译 exit 0;六项 B 定向 GTest **6/6 PASS**,完整扩展 filter **139/139 PASS**;旧 fixture 仅修正 16-bit 四样本合法长度,未放宽生产校验或原断言。证据:`artifacts/maplibre-particle/step097-b-rdc/validation-{build-core,focused-gtest,broad-gtest}.log` 与 `validation-status.json`。
- Mac 使用现有 OpenJDK 17 和 Android SDK:`:MapLibreAndroid:testOpenglDebugUnitTest --tests org.maplibre.android.maps.MapLibreMapDestroyListenerTest` **2/2 PASS**;`:MapLibreAndroid:compileOpenglDebugJavaWithJavac` exit 0。JUnit XML 位于 `platform/android/MapLibreAndroid/build/test-results/testOpenglDebugUnitTest/`;证据在 `android-validation-status.json`、`validation-native-{destroy-junit,compile}.log`。
- Owner Kotlin 单测使用独立临时 JVM 项目,源码及测试副本 SHA-256 与本工作树对应文件分别一致:生产 `36b437a0bc1573bafcf1a99ee37d4c438b72ea9ad5a9847fb0021f8093a3296a`,测试 `4c27cc3e82c0570b78b27aec46d565359eff11e18c2ff292e4188c158e8a4407`;JUnit **4/4 PASS**,0 fail/skip。第一次 offline 插件 marker 缺失,第二次正常解析并通过;详见 `owner-jvm-status.json` 和 `validation-owner-jvm-retry.log`。这是 owner helper 的 JVM 回归,不冒充 RN Host 集成。
- `git diff --check` exit 0,正式 .13 release commit `ddf20dec9` 的 `MapLibreMap.java` 无 `addOnDestroyListener()`,而当前 RN Bridge 调用该方法且 Gradle 精确锁定旧 .13;因此 **默认 RN consumer 编译仍未通过/不可宣称 PASS**,此阻塞不因上述单元测试通过而解除。
- Linux 独立工作树的 Gradle 仍有原进程,状态未确认完成,不能以 Mac 测试代替 Linux 结果;须等待并单独记录最终退出状态。
- 当前结论维持 `PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY`。STEP-097 整体未关闭;C、STEP-098、正式发布均 NOT RUN。未授权改写 .13 tag/manifest/产物,不把当前不兼容代码推入正式消费基线。
## Git 阻塞检查点(2026-09-26,后续事实)
- 为避免将当前无法和正式 .13 AAR 配对编译的新 RN bridge 提交进 `particle/main`,RDC 以 `b1239ac3b1554943200e6fbf14321459ccc86527` 创建独立 worktree,逐文件 SHA-256 比对 21 个 B 源码/测试/文档,并 scoped commit/push 到 `step097-b-blocked-20260926`。
- 首个 checkpoint commit:`e463ba04ce956a54eed02b77f981eeeedd9a5534`;独立远端回读相符。主线 `origin/particle/main=b1239ac3b1554943200e6fbf14321459ccc86527` 不变。原 Mac dirty B 工作树及用户 STEP-098 不变;发布资产无差异。
- Git 保存成果不等于 B 验收通过;B 仍 `PARTIAL / BLOCKED_NATIVE_AAR_COMPATIBILITY`,待 Integration / 发行流程明确新 Native/RN 同版配对或实现不依赖新 ABI 的可靠替代方案。不得启动 C、STEP-098 或覆盖 .13。

View File

@@ -9,7 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@maplibre/maplibre-react-native": "11.3.6", "@maplibre/maplibre-react-native": "11.3.6",
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz", "@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"expo": "56.0.4", "expo": "56.0.4",
"expo-asset": "~56.0.14", "expo-asset": "~56.0.14",
"expo-file-system": "56.0.11", "expo-file-system": "56.0.11",
@@ -1762,9 +1762,9 @@
} }
}, },
"node_modules/@navsea/maplibre-particle-rn": { "node_modules/@navsea/maplibre-particle-rn": {
"version": "13.4.1-particle.10", "version": "13.4.1-particle.13",
"resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz", "resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"integrity": "sha512-SNXvZ71A+vhrWzTxYCLGLGNkg6evtl8hydgdMUzB2Ft78sf5o+SCLQFtJ7ieLJl9r4kVEYdmprG8uwRdJDlhYg==", "integrity": "sha512-pXx41cV/1HcPQPAfOzvugsuVC1YIYtF4gWbRzsaDndEWFvXq0Kb1K+hONzzOKd7S5WU98XFLUpdVMGva4ZcZ9w==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peerDependencies": { "peerDependencies": {
"@maplibre/maplibre-react-native": "11.3.6", "@maplibre/maplibre-react-native": "11.3.6",

View File

@@ -9,7 +9,7 @@
"android": "expo run:android" "android": "expo run:android"
}, },
"dependencies": { "dependencies": {
"@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz", "@navsea/maplibre-particle-rn": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz",
"@maplibre/maplibre-react-native": "11.3.6", "@maplibre/maplibre-react-native": "11.3.6",
"expo": "56.0.4", "expo": "56.0.4",
"expo-asset": "~56.0.14", "expo-asset": "~56.0.14",

View File

@@ -16,6 +16,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20" implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20"
implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13" implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13"
compileOnly project(":maplibre_maplibre-react-native") compileOnly project(":maplibre_maplibre-react-native")
testImplementation "junit:junit:4.13.2"
} }
react { react {

View File

@@ -0,0 +1,163 @@
package org.navsea.maplibre.particle.weather
import java.lang.ref.WeakReference
internal fun interface WeatherViewDetachCallback {
fun onDetached()
}
internal fun interface WeatherViewDetachRegistration {
fun remove()
}
internal fun interface WeatherViewDetachHost {
fun add(callback: WeatherViewDetachCallback): WeatherViewDetachRegistration
}
internal fun interface WeatherMapDestroyCallback {
fun onDestroyed()
}
internal fun interface WeatherMapDestroyRegistration {
fun remove()
}
internal fun interface WeatherMapDestroyHost<T : Any> {
fun add(target: T, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration
}
/** One view-tag-owned registration, with separate attachment and map-lifetime generations. */
internal class WeatherRuntimeAttachmentOwner<T : Any>(
private val host: WeatherViewDetachHost,
private val mapDestroyHost: WeatherMapDestroyHost<T>,
private val disposeRuntime: (T) -> Unit,
) : Runnable {
private var generation = 0L
private var mapGeneration = 0L
private var currentTarget: WeakReference<T>? = null
private var mapTarget: WeakReference<T>? = null
private var viewRegistration: WeatherViewDetachRegistration? = null
private var mapRegistration: WeatherMapDestroyRegistration? = null
private var destroyed = false
fun attach(target: T): Boolean {
if (destroyed) return false
val previousTarget = currentTarget?.get()
removeViewRegistration()
val attachmentGeneration = ++generation
currentTarget = WeakReference(target)
if (previousTarget != null && previousTarget !== target) disposeRuntime(previousTarget)
if (!trackMap(target)) return false
val ownerReference = WeakReference(this)
val targetReference = WeakReference(target)
val viewRegistration = host.add(WeatherViewDetachCallback {
val owner = ownerReference.get() ?: return@WeatherViewDetachCallback
val attachedTarget = targetReference.get()
if (attachedTarget != null) owner.disposeIfCurrent(attachedTarget, attachmentGeneration)
else owner.clearIfCurrent(attachmentGeneration)
})
if (!isCurrent(target, attachmentGeneration)) {
viewRegistration.remove()
return false
}
this.viewRegistration = viewRegistration
return true
}
fun detach(target: T): Boolean {
if (currentTarget?.get() !== target) return false
return disposeIfCurrent(target, generation)
}
fun destroy(): Boolean {
if (destroyed) return false
destroyed = true
val target = currentTarget?.get()
currentTarget = null
generation++
removeRegistrations()
mapTarget = null
mapGeneration++
if (target == null) return false
disposeRuntime(target)
return true
}
private fun destroyMapIfCurrent(target: T?, expectedMapGeneration: Long) {
if (destroyed || expectedMapGeneration != mapGeneration) return
val trackedMap = mapTarget?.get()
if (target != null && trackedMap !== target) return
val attachedTarget = currentTarget?.get()?.takeIf { it === trackedMap }
destroyed = true
currentTarget = null
mapTarget = null
generation++
mapGeneration++
removeRegistrations()
if (attachedTarget != null) disposeRuntime(attachedTarget)
}
override fun run() {
destroy()
}
private fun disposeIfCurrent(target: T, expectedGeneration: Long): Boolean {
if (!isCurrent(target, expectedGeneration)) return false
currentTarget = null
generation++
removeViewRegistration()
disposeRuntime(target)
return true
}
private fun clearIfCurrent(expectedGeneration: Long) {
if (expectedGeneration != generation) return
currentTarget = null
generation++
removeViewRegistration()
}
private fun isCurrent(target: T, expectedGeneration: Long) =
!destroyed && expectedGeneration == generation && currentTarget?.get() === target
private fun isTrackedMap(target: T, expectedMapGeneration: Long) =
!destroyed && expectedMapGeneration == mapGeneration && mapTarget?.get() === target
private fun trackMap(target: T): Boolean {
if (mapTarget?.get() === target && mapRegistration != null) return true
removeMapRegistration()
val expectedMapGeneration = ++mapGeneration
mapTarget = WeakReference(target)
val ownerReference = WeakReference(this)
val targetReference = WeakReference(target)
val registration = mapDestroyHost.add(target, WeatherMapDestroyCallback {
ownerReference.get()?.destroyMapIfCurrent(targetReference.get(), expectedMapGeneration)
})
if (!isTrackedMap(target, expectedMapGeneration)) {
registration.remove()
return false
}
mapRegistration = registration
return true
}
private fun removeRegistrations() {
removeViewRegistration()
removeMapRegistration()
}
private fun removeViewRegistration() {
val previous = viewRegistration
viewRegistration = null
previous?.remove()
}
private fun removeMapRegistration() {
val previous = mapRegistration
mapRegistration = null
previous?.remove()
}
}

View File

@@ -47,19 +47,21 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
) )
} }
val runtime = map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER)) val runtime = map.replaceWeatherRuntime(WeatherRuntime.Config(WeatherIds.SOURCE, WeatherIds.COLOR_LAYER, WeatherIds.PARTICLE_LAYER))
// MLRN drops its MapView by detaching it. This listener captures only that concrete map, if (!attachmentOwner(view).attach(map)) {
// disposes its map-owned facade, and cannot target a replacement view. map.disposeWeatherRuntime()
view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { return@withMapView reject(promise, "RUNTIME_DISPOSED", "The MapView attachment owner has been destroyed")
override fun onViewAttachedToWindow(v: View) = Unit }
override fun onViewDetachedFromWindow(v: View) { map.disposeWeatherRuntime() }
})
promise.resolve(snapshot(runtime)) promise.resolve(snapshot(runtime))
} }
@ReactMethod @ReactMethod
override fun detach(reactTag: Double, promise: Promise) = withMap(reactTag, promise) { map -> override fun detach(reactTag: Double, promise: Promise) = withMapView(reactTag, promise) { view, map ->
if (map.weatherRuntime == null) reject(promise, "RUNTIME_DISPOSED", "No attached WeatherRuntime exists for this map") val owner = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*>
else { map.disposeWeatherRuntime(); promise.resolve(null) } val hasRuntime = map.weatherRuntime != null
val detachedByOwner = (owner as? WeatherRuntimeAttachmentOwner<MapLibreMap>)?.detach(map) ?: false
if (!detachedByOwner) map.disposeWeatherRuntime()
if (!hasRuntime) reject(promise, "RUNTIME_DISPOSED", "No attached WeatherRuntime exists for this map")
else promise.resolve(null)
} }
@ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) } @ReactMethod override fun getState(reactTag: Double, promise: Promise) = withRuntime(reactTag, promise) { runtime -> promise.resolve(snapshot(runtime)) }
@@ -312,6 +314,41 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) :
if (runtime == null || runtime.isDisposed) reject(promise, "RUNTIME_DISPOSED", "WeatherRuntime is not attached to this map") else fn(runtime) if (runtime == null || runtime.isDisposed) reject(promise, "RUNTIME_DISPOSED", "WeatherRuntime is not attached to this map") else fn(runtime)
} }
private fun withMap(reactTag: Double, promise: Promise, fn: (MapLibreMap) -> Unit) = withMapView(reactTag, promise) { _, map -> fn(map) } private fun withMap(reactTag: Double, promise: Promise, fn: (MapLibreMap) -> Unit) = withMapView(reactTag, promise) { _, map -> fn(map) }
private fun attachmentOwner(view: View): WeatherRuntimeAttachmentOwner<MapLibreMap> {
val existing = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*>
if (existing != null) return existing as WeatherRuntimeAttachmentOwner<MapLibreMap>
val host = AndroidWeatherViewDetachHost(view)
return WeatherRuntimeAttachmentOwner(host, AndroidWeatherMapDestroyHost, MapLibreMap::disposeWeatherRuntime).also {
view.setTag(R.id.weather_runtime_attachment_owner, it)
}
}
private object AndroidWeatherMapDestroyHost : WeatherMapDestroyHost<MapLibreMap> {
override fun add(target: MapLibreMap, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration {
val registration = target.addOnDestroyListener(MapLibreMap.OnDestroyListener { callback.onDestroyed() })
return WeatherMapDestroyRegistration { registration.remove() }
}
}
private class AndroidWeatherViewDetachHost(private val view: View) : WeatherViewDetachHost {
override fun add(callback: WeatherViewDetachCallback): WeatherViewDetachRegistration {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) = callback.onDetached()
}
view.addOnAttachStateChangeListener(listener)
return object : WeatherViewDetachRegistration {
private var removed = false
override fun remove() {
if (removed) return
removed = true
view.removeOnAttachStateChangeListener(listener)
}
}
}
}
private fun withMapView(reactTag: Double, promise: Promise, fn: (MLRNMapView, MapLibreMap) -> Unit) { private fun withMapView(reactTag: Double, promise: Promise, fn: (MLRNMapView, MapLibreMap) -> Unit) {
if (!reactTag.isFinite() || reactTag <= 0 || reactTag != kotlin.math.floor(reactTag)) return reject(promise, "INVALID_ARGUMENT", "reactTag must be a positive integer") if (!reactTag.isFinite() || reactTag <= 0 || reactTag != kotlin.math.floor(reactTag)) return reject(promise, "INVALID_ARGUMENT", "reactTag must be a positive integer")
context.runOnUiQueueThread { context.runOnUiQueueThread {

View File

@@ -0,0 +1,3 @@
<resources>
<item name="weather_runtime_attachment_owner" type="id" />
</resources>

View File

@@ -0,0 +1,150 @@
package org.navsea.maplibre.particle.weather
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class WeatherRuntimeAttachmentOwnerTest {
private class FakeMap
private class FakeViewHost : WeatherViewDetachHost {
private data class Entry(val callback: WeatherViewDetachCallback, var active: Boolean = true)
private val entries = mutableListOf<Entry>()
val activeCount: Int
get() = entries.count { it.active }
fun callbackAt(index: Int) = entries[index].callback
override fun add(callback: WeatherViewDetachCallback): WeatherViewDetachRegistration {
val entry = Entry(callback)
entries += entry
return WeatherViewDetachRegistration { entry.active = false }
}
}
private class FakeMapHost : WeatherMapDestroyHost<FakeMap> {
private data class Entry(val map: FakeMap, val callback: WeatherMapDestroyCallback, var active: Boolean = true)
private val entries = mutableListOf<Entry>()
val activeCount: Int
get() = entries.count { it.active }
fun callbackAt(index: Int) = entries[index].callback
override fun add(target: FakeMap, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration {
val entry = Entry(target, callback)
entries += entry
return WeatherMapDestroyRegistration { entry.active = false }
}
}
@Test
fun sameViewCanAttachDetachOneHundredTimesWithoutAccumulatingListeners() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val map = FakeMap()
repeat(100) {
assertTrue(owner.attach(map))
assertEquals(1, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
val staleViewCallback = viewHost.callbackAt(it)
assertTrue(owner.detach(map))
assertEquals(0, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
staleViewCallback.onDetached()
assertEquals(it + 1, disposed.size)
assertFalse(owner.detach(map))
}
assertTrue(owner.attach(map))
val viewCallbackBeforeDestroy = viewHost.callbackAt(100)
val mapCallbackBeforeDestroy = mapHost.callbackAt(0)
assertTrue(owner.destroy())
assertEquals(0, viewHost.activeCount)
assertEquals(0, mapHost.activeCount)
assertEquals(101, disposed.size)
viewCallbackBeforeDestroy.onDetached()
mapCallbackBeforeDestroy.onDestroyed()
assertFalse(owner.destroy())
assertFalse(owner.attach(map))
assertEquals(101, disposed.size)
}
@Test
fun mapDestroyWithoutViewDetachDisposesExactlyOnceAndRemovesBothListeners() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val map = FakeMap()
assertTrue(owner.attach(map))
val mapDestroy = mapHost.callbackAt(0)
mapDestroy.onDestroyed()
assertEquals(listOf(map), disposed)
assertEquals(0, viewHost.activeCount)
assertEquals(0, mapHost.activeCount)
mapDestroy.onDestroyed()
assertFalse(owner.destroy())
assertFalse(owner.attach(map))
assertEquals(listOf(map), disposed)
}
@Test
fun mapDestroyedDuringViewDetachClosesOwnerWithoutDisposingTwice() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val map = FakeMap()
assertTrue(owner.attach(map))
val mapDestroy = mapHost.callbackAt(0)
assertTrue(owner.detach(map))
assertEquals(listOf(map), disposed)
assertEquals(0, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
mapDestroy.onDestroyed()
assertEquals(listOf(map), disposed)
assertEquals(0, mapHost.activeCount)
assertFalse(owner.attach(map))
assertFalse(owner.destroy())
}
@Test
fun destroyedOldMapCallbackCannotAffectReplacementGeneration() {
val viewHost = FakeViewHost()
val mapHost = FakeMapHost()
val disposed = mutableListOf<FakeMap>()
val owner = WeatherRuntimeAttachmentOwner(viewHost, mapHost) { disposed += it }
val oldMap = FakeMap()
val currentMap = FakeMap()
assertTrue(owner.attach(oldMap))
val oldViewDetach = viewHost.callbackAt(0)
val oldMapDestroy = mapHost.callbackAt(0)
assertTrue(owner.attach(currentMap))
assertEquals(listOf(oldMap), disposed)
assertEquals(1, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
oldViewDetach.onDetached()
oldMapDestroy.onDestroyed()
assertEquals(listOf(oldMap), disposed)
assertEquals(1, viewHost.activeCount)
assertEquals(1, mapHost.activeCount)
mapHost.callbackAt(1).onDestroyed()
assertEquals(listOf(oldMap, currentMap), disposed)
assertEquals(0, viewHost.activeCount)
assertEquals(0, mapHost.activeCount)
}
}

View File

@@ -729,12 +729,8 @@ jni::jboolean NativeMapView::setContinuousLayerRange(JNIEnv& env,
} }
jni::jboolean NativeMapView::setRenderSegmentPlan(JNIEnv& env, const jni::String& planJson) { jni::jboolean NativeMapView::setRenderSegmentPlan(JNIEnv& env, const jni::String& planJson) {
const auto plan = parseRenderSegmentPlanJSON(jni::Make<std::string>(env, planJson)); auto plan = renderSegmentPlanFromJSONOrInvalid(jni::Make<std::string>(env, planJson));
if (plan) return map->setRenderSegmentPlan(*plan); return map->setRenderSegmentPlan(std::move(plan));
RenderSegmentPlan invalid;
invalid.schemaVersion = 0;
map->setRenderSegmentPlan(std::move(invalid));
return false;
} }
jni::Local<jni::String> NativeMapView::getRenderSegmentDiagnostics(JNIEnv& env) { jni::Local<jni::String> NativeMapView::getRenderSegmentDiagnostics(JNIEnv& env) {

View File

@@ -12,6 +12,7 @@ import androidx.annotation.FloatRange;
import androidx.annotation.IntRange; import androidx.annotation.IntRange;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.Size; import androidx.annotation.Size;
import androidx.annotation.UiThread; import androidx.annotation.UiThread;
@@ -43,6 +44,7 @@ import org.maplibre.android.log.Logger;
import org.maplibre.android.offline.OfflineRegionDefinition; import org.maplibre.android.offline.OfflineRegionDefinition;
import org.maplibre.android.style.expressions.Expression; import org.maplibre.android.style.expressions.Expression;
import java.lang.ref.WeakReference;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -68,6 +70,7 @@ public final class MapLibreMap {
private final OnGesturesManagerInteractionListener onGesturesManagerInteractionListener; private final OnGesturesManagerInteractionListener onGesturesManagerInteractionListener;
private final List<Style.OnStyleLoaded> awaitingStyleGetters = new ArrayList<>(); private final List<Style.OnStyleLoaded> awaitingStyleGetters = new ArrayList<>();
private final List<OnDeveloperAnimationListener> developerAnimationStartedListeners; private final List<OnDeveloperAnimationListener> developerAnimationStartedListeners;
private final List<OnDestroyListener> onDestroyListeners = new ArrayList<>();
@Nullable @Nullable
private Style.OnStyleLoaded styleLoadedCallback; private Style.OnStyleLoaded styleLoadedCallback;
@@ -91,6 +94,19 @@ public final class MapLibreMap {
private boolean debugActive; private boolean debugActive;
private boolean started; private boolean started;
private boolean destroyed;
/** Internal map-lifetime observer used by map-owned integrations. */
@RestrictTo(RestrictTo.Scope.LIBRARY)
public interface OnDestroyListener {
void onDestroy();
}
/** Removable registration for an internal observer of this concrete map's destruction. */
@RestrictTo(RestrictTo.Scope.LIBRARY)
public interface OnDestroyListenerRegistration {
void remove();
}
MapLibreMap(NativeMap map, Transform transform, UiSettings ui, Projection projection, MapLibreMap(NativeMap map, Transform transform, UiSettings ui, Projection projection,
OnGesturesManagerInteractionListener listener, CameraChangeDispatcher cameraChangeDispatcher, OnGesturesManagerInteractionListener listener, CameraChangeDispatcher cameraChangeDispatcher,
@@ -254,6 +270,24 @@ public final class MapLibreMap {
} }
} }
/** Registers a removable observer for destruction of this concrete map instance. */
@NonNull
@RestrictTo(RestrictTo.Scope.LIBRARY)
public OnDestroyListenerRegistration addOnDestroyListener(@NonNull OnDestroyListener listener) {
if (destroyed) {
listener.onDestroy();
return () -> { };
}
onDestroyListeners.add(listener);
WeakReference<MapLibreMap> mapReference = new WeakReference<>(this);
return () -> {
MapLibreMap map = mapReference.get();
if (map != null) {
map.onDestroyListeners.remove(listener);
}
};
}
/** /**
* Called when the hosting Activity/Fragment onStart() method is called. * Called when the hosting Activity/Fragment onStart() method is called.
*/ */
@@ -304,6 +338,16 @@ public final class MapLibreMap {
* Called when the hosting Activity/Fragment onDestroy()/onDestroyView() method is called. * Called when the hosting Activity/Fragment onDestroy()/onDestroyView() method is called.
*/ */
void onDestroy() { void onDestroy() {
if (destroyed) {
return;
}
destroyed = true;
List<OnDestroyListener> listeners = new ArrayList<>(onDestroyListeners);
onDestroyListeners.clear();
for (OnDestroyListener listener : listeners) {
listener.onDestroy();
}
disposeWeatherRuntime();
locationComponent.onDestroy(); locationComponent.onDestroy();
if (style != null) { if (style != null) {
style.clear(); style.clear();

View File

@@ -0,0 +1,52 @@
package org.maplibre.android.maps;
import org.junit.Test;
import org.mockito.Mockito;
import java.util.Collections;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
/** Exercises the concrete MapLibreMap.onDestroy() dispatch path used by Android integrations. */
public class MapLibreMapDestroyListenerTest {
private MapLibreMap newMap() {
MapLibreMap map = new MapLibreMap(
null,
null,
null,
null,
null,
Mockito.mock(CameraChangeDispatcher.class),
Collections.emptyList());
map.injectLocationComponent(Mockito.mock(org.maplibre.android.location.LocationComponent.class));
return map;
}
@Test
public void onDestroyNotifiesRegisteredListenerOnceAndLateRegistrationImmediately() {
MapLibreMap map = newMap();
AtomicInteger notifications = new AtomicInteger();
map.addOnDestroyListener(notifications::incrementAndGet);
map.onDestroy();
map.onDestroy();
assertEquals(1, notifications.get());
map.addOnDestroyListener(notifications::incrementAndGet);
assertEquals(2, notifications.get());
}
@Test
public void removedListenerIsNotCalledByOnDestroy() {
MapLibreMap map = newMap();
AtomicInteger notifications = new AtomicInteger();
MapLibreMap.OnDestroyListenerRegistration registration =
map.addOnDestroyListener(notifications::incrementAndGet);
registration.remove();
map.onDestroy();
assertEquals(0, notifications.get());
}
}

View File

@@ -3163,12 +3163,10 @@ static void *windowScreenContext = &windowScreenContext;
- (BOOL)setRenderSegmentPlanJSON:(NSString *)planJSON { - (BOOL)setRenderSegmentPlanJSON:(NSString *)planJSON {
if (!_mbglMap) return NO; if (!_mbglMap) return NO;
const auto plan = mbgl::parseRenderSegmentPlanJSON(planJSON.UTF8String); const char *utf8 = planJSON.UTF8String;
if (plan) return self.mbglMap.setRenderSegmentPlan(*plan); const NSUInteger length = [planJSON lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
mbgl::RenderSegmentPlan invalid; auto plan = mbgl::renderSegmentPlanFromJSONOrInvalid(std::string(utf8, length));
invalid.schemaVersion = 0; return self.mbglMap.setRenderSegmentPlan(std::move(plan));
self.mbglMap.setRenderSegmentPlan(std::move(invalid));
return NO;
} }
- (NSString *)renderSegmentDiagnosticsJSON { - (NSString *)renderSegmentDiagnosticsJSON {

View File

@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstring>
#include <iterator> #include <iterator>
#include <limits> #include <limits>
#include <map> #include <map>
@@ -72,6 +73,84 @@ bool directionScaleIsCanonical(const float scale) {
return std::fabs(scale - canonical) <= tolerance; return std::fabs(scale - canonical) <= tolerance;
} }
uint32_t readU32LE(const uint8_t* data) {
return static_cast<uint32_t>(data[0]) | (static_cast<uint32_t>(data[1]) << 8) |
(static_cast<uint32_t>(data[2]) << 16) | (static_cast<uint32_t>(data[3]) << 24);
}
bool validNumericSamples(const DecodedFieldFrame& frame, const DecodedFieldPlane& plane) {
const uint64_t cells = static_cast<uint64_t>(frame.grid.width) * frame.grid.height;
size_t bytesPerSample = 0;
switch (plane.dtype) {
case FieldDType::Int16:
case FieldDType::UInt16:
bytesPerSample = 2;
break;
case FieldDType::UInt8:
case FieldDType::Int8:
bytesPerSample = 1;
break;
case FieldDType::Float32:
bytesPerSample = 4;
break;
case FieldDType::Bitset:
return false;
}
if (cells > std::numeric_limits<size_t>::max() / bytesPerSample ||
plane.storage->size() != static_cast<size_t>(cells) * bytesPerSample) return false;
for (uint64_t cell = 0; cell < cells; ++cell) {
// The shared validity mask is the protocol's no-data indicator. Invalid
// cells are deliberately ignored; their zero placeholder is not a field
// value and must not reject an otherwise usable frame.
if (((*frame.validityBits)[static_cast<size_t>(cell >> 3)] & (1u << (cell & 7u))) == 0) continue;
const uint8_t* sample = plane.storage->data();
double raw = 0.0;
switch (plane.dtype) {
case FieldDType::Int16: {
const auto offset = static_cast<size_t>(cell) * 2;
const uint16_t bits = static_cast<uint16_t>(sample[offset]) |
(static_cast<uint16_t>(sample[offset + 1]) << 8);
raw = static_cast<int16_t>(bits);
break;
}
case FieldDType::UInt16: {
const auto offset = static_cast<size_t>(cell) * 2;
raw = static_cast<uint16_t>(sample[offset]) |
(static_cast<uint16_t>(sample[offset + 1]) << 8);
break;
}
case FieldDType::UInt8:
raw = sample[static_cast<size_t>(cell)];
break;
case FieldDType::Int8:
raw = static_cast<int8_t>(sample[static_cast<size_t>(cell)]);
break;
case FieldDType::Float32: {
float value = 0.0f;
const uint32_t bits = readU32LE(sample + static_cast<size_t>(cell) * 4);
std::memcpy(&value, &bits, sizeof(value));
if (!std::isfinite(value)) return false;
raw = value;
break;
}
case FieldDType::Bitset:
return false;
}
if (!std::isfinite(raw)) return false;
const double scaledValue = raw * static_cast<double>(plane.scale);
if (!std::isfinite(scaledValue) ||
std::fabs(scaledValue) > static_cast<double>(std::numeric_limits<float>::max())) return false;
const float scaled = static_cast<float>(scaledValue);
const double decodedValue = static_cast<double>(scaled) + static_cast<double>(plane.offset);
if (!std::isfinite(decodedValue) ||
std::fabs(decodedValue) > static_cast<double>(std::numeric_limits<float>::max()) ||
!std::isfinite(static_cast<float>(decodedValue))) return false;
}
return true;
}
} // namespace } // namespace
FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedFieldProfileContract& contract, FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedFieldProfileContract& contract,
@@ -163,6 +242,10 @@ FieldProfileValidationResult FieldProfileValidator::validate(const ResolvedField
if (!u || !v || u->count != v->count || u->unitID != v->unitID) if (!u || !v || u->count != v->count || u->unitID != v->unitID)
return result(FieldProfileValidationCode::InvalidVelocityGroup, "incomplete velocity group"); return result(FieldProfileValidationCode::InvalidVelocityGroup, "incomplete velocity group");
} }
for (const auto plane : actual) {
if (plane->valueClass != FieldValueClass::Validity && !validNumericSamples(frame, *plane))
return result(FieldProfileValidationCode::InvalidFrame, "non-finite valid-cell numeric value");
}
return result(FieldProfileValidationCode::Accepted, "accepted"); return result(FieldProfileValidationCode::Accepted, "accepted");
} }

View File

@@ -7,6 +7,7 @@
#include <rapidjson/writer.h> #include <rapidjson/writer.h>
#include <optional> #include <optional>
#include <set>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
@@ -15,24 +16,37 @@ namespace mbgl {
inline std::optional<RenderSegmentPlan> parseRenderSegmentPlanJSON(const std::string& json) { inline std::optional<RenderSegmentPlan> parseRenderSegmentPlanJSON(const std::string& json) {
rapidjson::Document document; rapidjson::Document document;
if (document.Parse(json.c_str()).HasParseError() || !document.IsObject() || if (document.Parse<rapidjson::kParseValidateEncodingFlag>(json.data(), json.size()).HasParseError() ||
!document.IsObject() ||
!document.HasMember("schemaVersion") || !document["schemaVersion"].IsUint() || !document.HasMember("schemaVersion") || !document["schemaVersion"].IsUint() ||
document["schemaVersion"].GetUint() != 1 || !document.HasMember("segments") || document["schemaVersion"].GetUint() != 1 || !document.HasMember("segments") ||
!document["segments"].IsArray() || document.MemberCount() != 2) { !document["segments"].IsArray() || document.MemberCount() != 2) {
return std::nullopt; return std::nullopt;
} }
std::set<std::string_view> rootKeys;
for (auto member = document.MemberBegin(); member != document.MemberEnd(); ++member) {
const std::string_view name(member->name.GetString(), member->name.GetStringLength());
if (!rootKeys.insert(name).second || (name != "schemaVersion" && name != "segments")) {
return std::nullopt;
}
}
RenderSegmentPlan plan; RenderSegmentPlan plan;
for (const auto& value : document["segments"].GetArray()) { for (const auto& value : document["segments"].GetArray()) {
if (!value.IsObject() || !value.HasMember("strategy") || !value["strategy"].IsString() || if (!value.IsObject() || !value.HasMember("strategy") || !value["strategy"].IsString() ||
value.MemberCount() > 2) { value.MemberCount() > 2) {
return std::nullopt; return std::nullopt;
} }
std::set<std::string_view> segmentKeys;
for (auto member = value.MemberBegin(); member != value.MemberEnd(); ++member) { for (auto member = value.MemberBegin(); member != value.MemberEnd(); ++member) {
const std::string_view name(member->name.GetString(), member->name.GetStringLength()); const std::string_view name(member->name.GetString(), member->name.GetStringLength());
if (name != "strategy" && name != "startLayerId") return std::nullopt; if (!segmentKeys.insert(name).second || (name != "strategy" && name != "startLayerId")) {
return std::nullopt;
}
} }
RenderSegmentDefinition definition; RenderSegmentDefinition definition;
const std::string strategy = value["strategy"].GetString(); const auto& strategyValue = value["strategy"];
const std::string strategy(strategyValue.GetString(), strategyValue.GetStringLength());
if (strategy.find('\0') != std::string::npos) return std::nullopt;
if (strategy == "RETAINED") { if (strategy == "RETAINED") {
definition.strategy = RenderSegmentStrategy::Retained; definition.strategy = RenderSegmentStrategy::Retained;
} else if (strategy == "CONTINUOUS") { } else if (strategy == "CONTINUOUS") {
@@ -41,14 +55,26 @@ inline std::optional<RenderSegmentPlan> parseRenderSegmentPlanJSON(const std::st
return std::nullopt; return std::nullopt;
} }
if (value.HasMember("startLayerId")) { if (value.HasMember("startLayerId")) {
if (!value["startLayerId"].IsString()) return std::nullopt; const auto& startLayerId = value["startLayerId"];
definition.startLayerId = value["startLayerId"].GetString(); if (!startLayerId.IsString()) return std::nullopt;
std::string layerId(startLayerId.GetString(), startLayerId.GetStringLength());
if (layerId.find('\0') != std::string::npos) return std::nullopt;
definition.startLayerId = std::move(layerId);
} }
plan.segments.push_back(std::move(definition)); plan.segments.push_back(std::move(definition));
} }
return plan; return plan;
} }
// Android and iOS bridges use the same fail-closed conversion so malformed
// JSON always replaces (and therefore clears) a previously accepted plan.
inline RenderSegmentPlan renderSegmentPlanFromJSONOrInvalid(const std::string& json) {
if (auto plan = parseRenderSegmentPlanJSON(json)) return std::move(*plan);
RenderSegmentPlan invalid;
invalid.schemaVersion = 0;
return invalid;
}
template <typename Writer> template <typename Writer>
inline void writeOptionalString(Writer& writer, const char* key, const std::optional<std::string>& value) { inline void writeOptionalString(Writer& writer, const char* key, const std::optional<std::string>& value) {
writer.Key(key); writer.Key(key);

View File

@@ -945,3 +945,54 @@ TEST(RenderSegmentPlan, StrictJSONRejectsUnknownFieldsStrategyAndInvalidFirstBou
RetainedUnderlayRuntimeState state; RetainedUnderlayRuntimeState state;
EXPECT_FALSE(state.setRenderSegmentPlan(std::move(*parsed))); EXPECT_FALSE(state.setRenderSegmentPlan(std::move(*parsed)));
} }
TEST(RenderSegmentPlan, StrictJSONRejectsNulDuplicateKeysAndUnknownSchema) {
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":2,"segments":[{"strategy":"RETAINED"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"schemaVersion":1,"segments":[{"strategy":"RETAINED"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","strategy":"CONTINUOUS"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED\u0000suffix"}]})"));
EXPECT_FALSE(parseRenderSegmentPlanJSON(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS","startLayerId":"water\u0000suffix"}]})"));
std::string rawNul = R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"}]})";
rawNul.insert(rawNul.end() - 2, '\0');
rawNul.insert(rawNul.end() - 2, 'x');
EXPECT_FALSE(parseRenderSegmentPlanJSON(rawNul));
}
TEST(RenderSegmentPlan, InvalidJSONClearsRendererPlanAndValidPlanRecovers) {
constexpr auto validJSON =
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS","startLayerId":"water"}]})";
RetainedUnderlayRuntimeState state;
auto valid = renderSegmentPlanFromJSONOrInvalid(validJSON);
ASSERT_TRUE(state.setRenderSegmentPlan(std::move(valid)));
auto resolved = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
ASSERT_TRUE(resolved.valid);
auto invalid = renderSegmentPlanFromJSONOrInvalid(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED","strategy":"CONTINUOUS"}]})");
EXPECT_FALSE(state.setRenderSegmentPlan(std::move(invalid)));
auto cleared = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
EXPECT_FALSE(cleared.valid);
EXPECT_EQ(cleared.reason, "unsupported-schema-version");
auto missingBoundary = renderSegmentPlanFromJSONOrInvalid(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS"}]})");
EXPECT_FALSE(state.setRenderSegmentPlan(std::move(missingBoundary)));
EXPECT_FALSE(resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true).valid);
auto reversedBoundary = renderSegmentPlanFromJSONOrInvalid(
R"({"schemaVersion":1,"segments":[{"strategy":"RETAINED"},{"strategy":"CONTINUOUS","startLayerId":"water"},{"strategy":"RETAINED","startLayerId":"background"}]})");
ASSERT_TRUE(state.setRenderSegmentPlan(std::move(reversedBoundary)));
auto reversed = resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true);
EXPECT_FALSE(reversed.valid);
EXPECT_EQ(reversed.reason, "boundary-order-invalid");
auto recovered = renderSegmentPlanFromJSONOrInvalid(validJSON);
ASSERT_TRUE(state.setRenderSegmentPlan(std::move(recovered)));
EXPECT_TRUE(resolveRenderSegmentPlan(state.renderSegmentPlanConfig(), segmentLayerIds(), true).valid);
}