From f3a44c7d5fac2046640418632b265dbee701c8f7 Mon Sep 17 00:00:00 2001 From: tei Date: Sun, 27 Sep 2026 08:41:27 +0900 Subject: [PATCH] feat(step097): preserve B fixes for unified V15 source --- .../STEP-097_V13三轮问题去重与修复矩阵_v1.md | 14 +- .../STEP-097_V13三轮审查问题合并修复_v1.md | 18 ++ .../STEP-097_V13修复后集中验证清单_v1.md | 13 ++ .../STEP-097_B_独立验收与版本阻塞报告_v1.md | 36 ++++ .../package-lock.json | 8 +- .../MapLibreParticleRNTestHost/package.json | 2 +- .../maplibre-particle-rn/android/build.gradle | 1 + .../weather/WeatherRuntimeAttachmentOwner.kt | 163 ++++++++++++++++++ .../weather/WeatherRuntimeBridgeModule.kt | 55 +++++- .../android/src/main/res/values/ids.xml | 3 + .../WeatherRuntimeAttachmentOwnerTest.kt | 150 ++++++++++++++++ .../src/cpp/native_map_view.cpp | 8 +- .../maplibre/android/maps/MapLibreMap.java | 44 +++++ .../maps/MapLibreMapDestroyListenerTest.java | 52 ++++++ platform/ios/src/MLNMapView.mm | 10 +- .../field/field_profile_validator.cpp | 83 +++++++++ src/mbgl/renderer/render_segment_json.hpp | 36 +++- .../renderer/retained_underlay_cache.test.cpp | 51 ++++++ 18 files changed, 711 insertions(+), 36 deletions(-) create mode 100644 docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md create mode 100644 packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwner.kt create mode 100644 packages/maplibre-particle-rn/android/src/main/res/values/ids.xml create mode 100644 packages/maplibre-particle-rn/android/src/test/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwnerTest.kt create mode 100644 platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapDestroyListenerTest.java diff --git a/docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md b/docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md index f3d074ea6..80ff5acb2 100644 --- a/docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md +++ b/docs/30_设计/STEP-097_V13三轮问题去重与修复矩阵_v1.md @@ -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 后合法增加 | | 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 | -| 08 N05 | `render_segment_json.cpp` C 字符串解析 | invalid 清旧计划 | JSON/parser;NUL、重复键、恢复 | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core、桥接 | -| 09 N06 | `raster_array_source.cpp` 入库早于物化数值校验 | validity=0/noData 合法 | source/store;好旧帧→坏高 revision | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Core | -| 10 N07 | Android attach map listener 重复注册 | detach/destroy 可重入 | Android bridge;100 次 attach/detach | WAITING_RDC_BUILD_AND_RUNTIME / 待提交 / Android | +| 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 | 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 | 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 | | 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 | @@ -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 其余项目未开始 | | 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 资源验收仍待测 | -| 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 | | 22 F08 | source/tile 等全局失效 | 无依赖图前不缩窄 | 后续依赖设计 | DEFERRED_DESIGN / 无 / Core | | 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,避免错误缓存跨帧复用。 - 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)。尚未实施。 ## 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。 - 日志按资源窗口分别记录: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。 + +## 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 独立报告。 diff --git a/docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md b/docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md index e063a7b5f..fb61e209e 100644 --- a/docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md +++ b/docs/50_Step记录/STEP-097_V13三轮审查问题合并修复_v1.md @@ -61,3 +61,21 @@ ### 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 待用户指令。 + +## 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 发布资产不变。 diff --git a/docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md b/docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md index df9b7eddb..4bd9e9776 100644 --- a/docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md +++ b/docs/60_测试/STEP-097_V13修复后集中验证清单_v1.md @@ -115,3 +115,16 @@ build-step097-metal-host/mbgl-test-runner --gtest_filter='Particle*.*:Source.Ras ### 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 待用户指令。 + +## 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 均不得越过此门槛。 diff --git a/docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md b/docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md new file mode 100644 index 000000000..26edcbf3d --- /dev/null +++ b/docs/报告/STEP-097_B_独立验收与版本阻塞报告_v1.md @@ -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。 diff --git a/integration/react-native/MapLibreParticleRNTestHost/package-lock.json b/integration/react-native/MapLibreParticleRNTestHost/package-lock.json index 384ae8be4..d190fdd96 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/package-lock.json +++ b/integration/react-native/MapLibreParticleRNTestHost/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@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-asset": "~56.0.14", "expo-file-system": "56.0.11", @@ -1762,9 +1762,9 @@ } }, "node_modules/@navsea/maplibre-particle-rn": { - "version": "13.4.1-particle.10", - "resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.10.tgz", - "integrity": "sha512-SNXvZ71A+vhrWzTxYCLGLGNkg6evtl8hydgdMUzB2Ft78sf5o+SCLQFtJ7ieLJl9r4kVEYdmprG8uwRdJDlhYg==", + "version": "13.4.1-particle.13", + "resolved": "https://pbf.jpcn.ccwu.cc/npm/navsea-maplibre-particle-rn-13.4.1-particle.13.tgz", + "integrity": "sha512-pXx41cV/1HcPQPAfOzvugsuVC1YIYtF4gWbRzsaDndEWFvXq0Kb1K+hONzzOKd7S5WU98XFLUpdVMGva4ZcZ9w==", "license": "BSD-2-Clause", "peerDependencies": { "@maplibre/maplibre-react-native": "11.3.6", diff --git a/integration/react-native/MapLibreParticleRNTestHost/package.json b/integration/react-native/MapLibreParticleRNTestHost/package.json index 634756075..3a895e98b 100644 --- a/integration/react-native/MapLibreParticleRNTestHost/package.json +++ b/integration/react-native/MapLibreParticleRNTestHost/package.json @@ -9,7 +9,7 @@ "android": "expo run:android" }, "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", "expo": "56.0.4", "expo-asset": "~56.0.14", diff --git a/packages/maplibre-particle-rn/android/build.gradle b/packages/maplibre-particle-rn/android/build.gradle index 961ff8568..e376f6314 100644 --- a/packages/maplibre-particle-rn/android/build.gradle +++ b/packages/maplibre-particle-rn/android/build.gradle @@ -16,6 +16,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.20" implementation "cc.navsea.maplibre:android-sdk-particle-opengl:13.4.1-particle.13" compileOnly project(":maplibre_maplibre-react-native") + testImplementation "junit:junit:4.13.2" } react { diff --git a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwner.kt b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwner.kt new file mode 100644 index 000000000..d8d613ebf --- /dev/null +++ b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwner.kt @@ -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 { + fun add(target: T, callback: WeatherMapDestroyCallback): WeatherMapDestroyRegistration +} + +/** One view-tag-owned registration, with separate attachment and map-lifetime generations. */ +internal class WeatherRuntimeAttachmentOwner( + private val host: WeatherViewDetachHost, + private val mapDestroyHost: WeatherMapDestroyHost, + private val disposeRuntime: (T) -> Unit, +) : Runnable { + private var generation = 0L + private var mapGeneration = 0L + private var currentTarget: WeakReference? = null + private var mapTarget: WeakReference? = 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() + } +} diff --git a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt index 0db0b1139..f0b40f392 100644 --- a/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt +++ b/packages/maplibre-particle-rn/android/src/main/java/org/navsea/maplibre/particle/weather/WeatherRuntimeBridgeModule.kt @@ -47,19 +47,21 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : ) } 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, - // disposes its map-owned facade, and cannot target a replacement view. - view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) = Unit - override fun onViewDetachedFromWindow(v: View) { map.disposeWeatherRuntime() } - }) + if (!attachmentOwner(view).attach(map)) { + map.disposeWeatherRuntime() + return@withMapView reject(promise, "RUNTIME_DISPOSED", "The MapView attachment owner has been destroyed") + } promise.resolve(snapshot(runtime)) } @ReactMethod - override fun detach(reactTag: Double, promise: Promise) = withMap(reactTag, promise) { map -> - if (map.weatherRuntime == null) reject(promise, "RUNTIME_DISPOSED", "No attached WeatherRuntime exists for this map") - else { map.disposeWeatherRuntime(); promise.resolve(null) } + override fun detach(reactTag: Double, promise: Promise) = withMapView(reactTag, promise) { view, map -> + val owner = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*> + val hasRuntime = map.weatherRuntime != null + val detachedByOwner = (owner as? WeatherRuntimeAttachmentOwner)?.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)) } @@ -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) } private fun withMap(reactTag: Double, promise: Promise, fn: (MapLibreMap) -> Unit) = withMapView(reactTag, promise) { _, map -> fn(map) } + + private fun attachmentOwner(view: View): WeatherRuntimeAttachmentOwner { + val existing = view.getTag(R.id.weather_runtime_attachment_owner) as? WeatherRuntimeAttachmentOwner<*> + if (existing != null) return existing as WeatherRuntimeAttachmentOwner + val host = AndroidWeatherViewDetachHost(view) + return WeatherRuntimeAttachmentOwner(host, AndroidWeatherMapDestroyHost, MapLibreMap::disposeWeatherRuntime).also { + view.setTag(R.id.weather_runtime_attachment_owner, it) + } + } + + private object AndroidWeatherMapDestroyHost : WeatherMapDestroyHost { + 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) { if (!reactTag.isFinite() || reactTag <= 0 || reactTag != kotlin.math.floor(reactTag)) return reject(promise, "INVALID_ARGUMENT", "reactTag must be a positive integer") context.runOnUiQueueThread { diff --git a/packages/maplibre-particle-rn/android/src/main/res/values/ids.xml b/packages/maplibre-particle-rn/android/src/main/res/values/ids.xml new file mode 100644 index 000000000..f594ca083 --- /dev/null +++ b/packages/maplibre-particle-rn/android/src/main/res/values/ids.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/maplibre-particle-rn/android/src/test/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwnerTest.kt b/packages/maplibre-particle-rn/android/src/test/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwnerTest.kt new file mode 100644 index 000000000..9a5907997 --- /dev/null +++ b/packages/maplibre-particle-rn/android/src/test/java/org/navsea/maplibre/particle/weather/WeatherRuntimeAttachmentOwnerTest.kt @@ -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() + + 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 { + private data class Entry(val map: FakeMap, val callback: WeatherMapDestroyCallback, var active: Boolean = true) + private val entries = mutableListOf() + + 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() + 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() + 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() + 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() + 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) + } +} diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index 2e74f954b..f043dcd73 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -729,12 +729,8 @@ jni::jboolean NativeMapView::setContinuousLayerRange(JNIEnv& env, } jni::jboolean NativeMapView::setRenderSegmentPlan(JNIEnv& env, const jni::String& planJson) { - const auto plan = parseRenderSegmentPlanJSON(jni::Make(env, planJson)); - if (plan) return map->setRenderSegmentPlan(*plan); - RenderSegmentPlan invalid; - invalid.schemaVersion = 0; - map->setRenderSegmentPlan(std::move(invalid)); - return false; + auto plan = renderSegmentPlanFromJSONOrInvalid(jni::Make(env, planJson)); + return map->setRenderSegmentPlan(std::move(plan)); } jni::Local NativeMapView::getRenderSegmentDiagnostics(JNIEnv& env) { diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java index 98259c8a6..c28440064 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/MapLibreMap.java @@ -12,6 +12,7 @@ import androidx.annotation.FloatRange; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; import androidx.annotation.Size; import androidx.annotation.UiThread; @@ -43,6 +44,7 @@ import org.maplibre.android.log.Logger; import org.maplibre.android.offline.OfflineRegionDefinition; import org.maplibre.android.style.expressions.Expression; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; @@ -68,6 +70,7 @@ public final class MapLibreMap { private final OnGesturesManagerInteractionListener onGesturesManagerInteractionListener; private final List awaitingStyleGetters = new ArrayList<>(); private final List developerAnimationStartedListeners; + private final List onDestroyListeners = new ArrayList<>(); @Nullable private Style.OnStyleLoaded styleLoadedCallback; @@ -91,6 +94,19 @@ public final class MapLibreMap { private boolean debugActive; 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, 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 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. */ @@ -304,6 +338,16 @@ public final class MapLibreMap { * Called when the hosting Activity/Fragment onDestroy()/onDestroyView() method is called. */ void onDestroy() { + if (destroyed) { + return; + } + destroyed = true; + List listeners = new ArrayList<>(onDestroyListeners); + onDestroyListeners.clear(); + for (OnDestroyListener listener : listeners) { + listener.onDestroy(); + } + disposeWeatherRuntime(); locationComponent.onDestroy(); if (style != null) { style.clear(); diff --git a/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapDestroyListenerTest.java b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapDestroyListenerTest.java new file mode 100644 index 000000000..0f76cdb8b --- /dev/null +++ b/platform/android/MapLibreAndroid/src/test/java/org/maplibre/android/maps/MapLibreMapDestroyListenerTest.java @@ -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()); + } +} diff --git a/platform/ios/src/MLNMapView.mm b/platform/ios/src/MLNMapView.mm index 6043df59d..09fc84cf9 100644 --- a/platform/ios/src/MLNMapView.mm +++ b/platform/ios/src/MLNMapView.mm @@ -3163,12 +3163,10 @@ static void *windowScreenContext = &windowScreenContext; - (BOOL)setRenderSegmentPlanJSON:(NSString *)planJSON { if (!_mbglMap) return NO; - const auto plan = mbgl::parseRenderSegmentPlanJSON(planJSON.UTF8String); - if (plan) return self.mbglMap.setRenderSegmentPlan(*plan); - mbgl::RenderSegmentPlan invalid; - invalid.schemaVersion = 0; - self.mbglMap.setRenderSegmentPlan(std::move(invalid)); - return NO; + const char *utf8 = planJSON.UTF8String; + const NSUInteger length = [planJSON lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + auto plan = mbgl::renderSegmentPlanFromJSONOrInvalid(std::string(utf8, length)); + return self.mbglMap.setRenderSegmentPlan(std::move(plan)); } - (NSString *)renderSegmentDiagnosticsJSON { diff --git a/src/mbgl/particle/field/field_profile_validator.cpp b/src/mbgl/particle/field/field_profile_validator.cpp index b78a99482..9cdd1e2f0 100644 --- a/src/mbgl/particle/field/field_profile_validator.cpp +++ b/src/mbgl/particle/field/field_profile_validator.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -72,6 +73,84 @@ bool directionScaleIsCanonical(const float scale) { return std::fabs(scale - canonical) <= tolerance; } +uint32_t readU32LE(const uint8_t* data) { + return static_cast(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); +} + +bool validNumericSamples(const DecodedFieldFrame& frame, const DecodedFieldPlane& plane) { + const uint64_t cells = static_cast(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::max() / bytesPerSample || + plane.storage->size() != static_cast(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(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(cell) * 2; + const uint16_t bits = static_cast(sample[offset]) | + (static_cast(sample[offset + 1]) << 8); + raw = static_cast(bits); + break; + } + case FieldDType::UInt16: { + const auto offset = static_cast(cell) * 2; + raw = static_cast(sample[offset]) | + (static_cast(sample[offset + 1]) << 8); + break; + } + case FieldDType::UInt8: + raw = sample[static_cast(cell)]; + break; + case FieldDType::Int8: + raw = static_cast(sample[static_cast(cell)]); + break; + case FieldDType::Float32: { + float value = 0.0f; + const uint32_t bits = readU32LE(sample + static_cast(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(plane.scale); + if (!std::isfinite(scaledValue) || + std::fabs(scaledValue) > static_cast(std::numeric_limits::max())) return false; + const float scaled = static_cast(scaledValue); + const double decodedValue = static_cast(scaled) + static_cast(plane.offset); + if (!std::isfinite(decodedValue) || + std::fabs(decodedValue) > static_cast(std::numeric_limits::max()) || + !std::isfinite(static_cast(decodedValue))) return false; + } + return true; +} + } // namespace 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) 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"); } diff --git a/src/mbgl/renderer/render_segment_json.hpp b/src/mbgl/renderer/render_segment_json.hpp index 9fbf83fec..553b4c980 100644 --- a/src/mbgl/renderer/render_segment_json.hpp +++ b/src/mbgl/renderer/render_segment_json.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -15,24 +16,37 @@ namespace mbgl { inline std::optional parseRenderSegmentPlanJSON(const std::string& json) { rapidjson::Document document; - if (document.Parse(json.c_str()).HasParseError() || !document.IsObject() || + if (document.Parse(json.data(), json.size()).HasParseError() || + !document.IsObject() || !document.HasMember("schemaVersion") || !document["schemaVersion"].IsUint() || document["schemaVersion"].GetUint() != 1 || !document.HasMember("segments") || !document["segments"].IsArray() || document.MemberCount() != 2) { return std::nullopt; } + std::set 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; for (const auto& value : document["segments"].GetArray()) { if (!value.IsObject() || !value.HasMember("strategy") || !value["strategy"].IsString() || value.MemberCount() > 2) { return std::nullopt; } + std::set segmentKeys; for (auto member = value.MemberBegin(); member != value.MemberEnd(); ++member) { 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; - 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") { definition.strategy = RenderSegmentStrategy::Retained; } else if (strategy == "CONTINUOUS") { @@ -41,14 +55,26 @@ inline std::optional parseRenderSegmentPlanJSON(const std::st return std::nullopt; } if (value.HasMember("startLayerId")) { - if (!value["startLayerId"].IsString()) return std::nullopt; - definition.startLayerId = value["startLayerId"].GetString(); + const auto& startLayerId = value["startLayerId"]; + 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)); } 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 inline void writeOptionalString(Writer& writer, const char* key, const std::optional& value) { writer.Key(key); diff --git a/test/renderer/retained_underlay_cache.test.cpp b/test/renderer/retained_underlay_cache.test.cpp index b675bce7b..515f78b8b 100644 --- a/test/renderer/retained_underlay_cache.test.cpp +++ b/test/renderer/retained_underlay_cache.test.cpp @@ -945,3 +945,54 @@ TEST(RenderSegmentPlan, StrictJSONRejectsUnknownFieldsStrategyAndInvalidFirstBou RetainedUnderlayRuntimeState state; 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); +}