diff --git a/CMakeLists.txt b/CMakeLists.txt index b45935104..e6515f976 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -216,6 +216,7 @@ list(APPEND SRC_FILES ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_runtime.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_binding.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_validator.cpp + ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/field_profile_contract_json.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/ingress/qbin_v2_decoder.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/particle/field/local_particle_field_feed.cpp ${PROJECT_SOURCE_DIR}/src/mbgl/shaders/shader_program_base.cpp @@ -225,6 +226,7 @@ list(APPEND INCLUDE_FILES ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_data_adapter.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_contract.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_validator.hpp + ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_profile_contract_json.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_runtime.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/field/field_binding.hpp ${PROJECT_SOURCE_DIR}/include/mbgl/particle/ingress/qbin_v2_decoder.hpp diff --git a/docs/50_Step记录/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_v2.md b/docs/50_Step记录/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_v2.md new file mode 100644 index 000000000..0f7cb52ee --- /dev/null +++ b/docs/50_Step记录/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_v2.md @@ -0,0 +1,44 @@ +# STEP-078 — QBIN2 V4 Android / JNI / RN 桥接实现 v2 + +状态:完成 + +日期:2026-09-13 + +## 目标与基线 + +- 开始 HEAD:`4b5192e224613a885c1d038594fe06c5eb58df50`。 +- 保留 v1 未提交 bridge 草稿,并修正其已确认的 ABI/契约问题。 +- 开始时已有 STEP-062 两处 dirty、历史 untracked 与 `.codex-build-logs/`;未清理、改写、暂存或提交它们。 + +## 完成项 + +- [x] RN → TurboModule → Kotlin → Java → MapLibreMap → NativeMap/NativeMapView → JNI → `RasterArraySource` 完整贯通独立 V4 submit、两类 binding、forecast time/state/speed 与两类 clear API。 +- [x] `EncodedColorBindingInput.expectedUnitID` 端到端传递;不在 bridge 默认为 NONE。 +- [x] `ParticleBinding` ABI 固定 U/V/direction 三组 primitive selector slot;Vector 只接受 active U/V,Direction 只接受 active direction,inactive slot 仅允许 `(0,0)`。 +- [x] forecast playback state 与 speed 分别调用 `setForecastPlaybackState`、`setForecastPlaybackSpeed`,没有互相覆盖。 +- [x] frozen `contractJson` 使用 `contractSchemaVersion`、`requiredFlag` 等 Core 同名字段;新增无 Android/JNI 依赖的纯 C++ parser,JNI 仅调用该 parser。 +- [x] parser 单测覆盖合法 schema、缺字段、未知 schema、整数越界、重复 selector、非法 enum、requiredPlanes 上限及非法 profileID。 +- [x] QBIN 仍仅通过绝对路径或 `file://` local file URI 进入 Kotlin,沿用读取前后 1..64 MiB guard;没有 URL、HTTP、Base64 或 JS number array。 +- [x] Core 十种 `RasterArrayEncodedFieldSubmitResult` 保持一一 `.name` resolve;source 缺失/类型不符只在私有 JNI `0` sentinel 转为 `SOURCE_MISSING` Promise rejection,不伪装 Core result。 + +## 实际修改范围 + +- RN/TurboModule/Kotlin:`FieldRuntime.ts`、`types.ts`、`NativeWeatherRuntimeBridge.ts`、`WeatherRuntimeBridgeModule.kt`。 +- Java/JNI:`WeatherRuntime`、`MapLibreMap`、`NativeMap`、`NativeMapView`、`native_map_view.hpp/.cpp`。 +- 新增 provider-neutral parser:`field_profile_contract_json.hpp/.cpp` 及 `field_profile_contract_json.test.cpp`,并注册 Core/Test CMake。 +- 未修改 Renderer、GPU、shader、Test Host、NavSea、STEP-073~076 已冻结 production 语义。 + +## Linux 与静态验证 + +- [x] 在 `particle-codex` tmux session 中通过全局 `build-output-throttle` 重建 `mbgl-test-runner`。 +- [x] parser 单测、STEP-076 Source V4、STEP-075 Binding、STEP-074 QBIN/Ingress、STEP-073 Runtime/Profile 与 V3 Source 过滤回归:PASS。 +- [x] package TypeScript `tsc --noEmit`:PASS。 +- [x] TurboModule schema combine/codegen:PASS(仅 `NativeWeatherRuntimeBridge` Spec)。 +- [x] Java native declarations 与 C++ JNI registration V4 name set 静态一一核对:PASS。 +- [x] `git diff --check`:PASS。 + +## 未执行、阻塞与停止 + +- Android Kotlin/Java/JNI 真实编译、Gradle、APK、ADB、实体机:按任务约束未执行,待后续一次性 Android 验收。 +- 未执行 iOS/Metal 或 Jest。 +- 无实现阻塞;本 Step 到此停止,不进入 STEP-079。 diff --git a/docs/报告/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_Codex完成报告_v2.md b/docs/报告/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_Codex完成报告_v2.md new file mode 100644 index 000000000..cd96a3dde --- /dev/null +++ b/docs/报告/STEP-078_QBIN2_V4_Android_JNI_RN桥接实现_Codex完成报告_v2.md @@ -0,0 +1,35 @@ +# STEP-078 QBIN2 V4 Android / JNI / RN 桥接实现 Codex 完成报告 v2 + +## 结果 + +V4 bridge 已完整闭环并与 V3 隔离。v1 保留了 encoded QBIN submit、local-file guard、V4 clear/time 骨架及 Java/JNI transport;本次修正了 contract 字段、Color unit assertion、mode-aware Particle selector,并拆分 forecast state/speed。binding 不再是占位 rejection。 + +## 设计与错误边界 + +- `contractJson` 有唯一的 provider-neutral C++ parser;其只把 frozen control-plane JSON 转为 `ResolvedFieldProfileContract`,随后仍由 Core structural validation 和 `FieldProfileValidator` 裁决。 +- Color 固定传递 `fieldKey/(semanticID,groupID)/expectedValueClass/expectedUnitID`。 +- Particle 固定 primitive ABI slots 为 U、V、direction;active selector 要求 uint16 范围与非零 semantic,inactive selector 只能为零对。 +- V4 Core result 保持十种稳定字符串。JNI 的 `0` 仅为 source missing/type mismatch 私有 sentinel,最终成为 `SOURCE_MISSING` rejection;不占用 Core enum。 +- state 只调 Core playback state,speed 只调 Core playback speed。 + +## 实际修改文件 + +`CMakeLists.txt`、`test/CMakeLists.txt`、新增 parser header/source/test;`packages/maplibre-particle-rn/src/{FieldRuntime.ts,NativeWeatherRuntimeBridge.ts,types.ts}`;Kotlin `WeatherRuntimeBridgeModule.kt`;Android Java `WeatherRuntime.java`、`MapLibreMap.java`、`NativeMap.java`、`NativeMapView.java`;以及 `native_map_view.hpp/.cpp`;本 Step 记录、本报告和`粒子开发记录.md`。 + +本次没有修改 Renderer/GPU/shader、Test Host、NavSea 或 STEP-073~076 已冻结 Core 业务语义。任务开始前 STEP-062 dirty、历史 untracked、`.codex-build-logs/`、generated/android/.expo 和 tgz 均没有纳入暂存或提交。 + +## Linux 验证 + +- `particle-codex` 中限流构建 `mbgl-test-runner`:PASS。 +- `ParticleFieldProfileContractJson.*`、Source V4、Binding、QBIN/Ingress、Runtime/Profile、V3 Source 定向过滤:PASS。 +- package TypeScript:PASS;TurboModule schema combine/codegen:PASS。 +- JNI Java declaration ↔ C++ registration 静态 name-set:PASS。 +- `git diff --check`:PASS。 + +所有 noisy build/test 都使用全局 `build-output-throttle`,日志放在 `/tmp/step078-build-logs`,没有触碰受保护的既有 build 输出。 + +## Android 未验证与 Git 收尾 + +Android Kotlin/Java/JNI 的真实编译、Gradle、APK、ADB 和实体机均按任务要求未执行,须在后续一次性 Android 验收中完成;iOS/Metal 与 Jest 亦未执行。无实现阻塞。 + +scoped commit 的 hash、message、push result、remote branch 与 Final HEAD 在本 Step Git 收尾命令完成后以该命令输出为准;目标为 `origin/particle/main`。完成后立即停止,不进入 STEP-079。 diff --git a/include/mbgl/particle/field/field_profile_contract_json.hpp b/include/mbgl/particle/field/field_profile_contract_json.hpp new file mode 100644 index 000000000..38ca58517 --- /dev/null +++ b/include/mbgl/particle/field/field_profile_contract_json.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include +#include + +namespace mbgl::particle { + +// Parses only the frozen V4 control-plane JSON schema. QBIN decoding and +// profile/frame business validation intentionally remain in their Core owners. +std::optional parseResolvedFieldProfileContractJson(const std::string& json); + +} // namespace mbgl::particle 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 b48aa9606..a283b90b9 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 @@ -104,6 +104,30 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : catch (error: IllegalStateException) { reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the field") } } } + /** V4 transports only a bounded local raw QBIN file plus small primitive control-plane values. */ + @ReactMethod override fun submitEncodedField(reactTag: Double, fieldKey: String, fieldID: String, generation: Double, dataVersion: Double, contractJson: String, qbinFileUri: String, promise: Promise) { + if (!validEncodedId(fieldKey) || !validEncodedId(fieldID) || !validSafeInteger(generation, true) || !validSafeInteger(dataVersion, false) || contractJson.isEmpty() || contractJson.toByteArray(Charsets.UTF_8).size > MAX_CONTRACT_UTF8_BYTES) return reject(promise, "INVALID_ARGUMENT", "invalid V4 encoded field control-plane argument") + val payload = try { readLocalPayload(qbinFileUri) } catch (e: Exception) { return reject(promise, "PAYLOAD_FILE_UNREADABLE", e.message ?: "QBIN file is unreadable") } + if (payload.isEmpty()) return reject(promise, "PAYLOAD_FILE_UNREADABLE", "QBIN file is empty") + withRuntime(reactTag, promise) { runtime -> try { promise.resolve(runtime.submitEncodedField(fieldKey, fieldID, generation.toLong(), dataVersion.toLong(), contractJson, payload).name) } catch (e: IllegalStateException) { reject(promise, if (e.message == "SOURCE_MISSING") "SOURCE_MISSING" else "RUNTIME_DISPOSED", e.message ?: "V4 runtime unavailable") } catch (e: IllegalArgumentException) { reject(promise, "INVALID_ARGUMENT", e.message ?: "invalid encoded field") } } + } + @ReactMethod override fun clearEncodedField(reactTag: Double, fieldKey: String, promise: Promise) { if (!validEncodedId(fieldKey)) return reject(promise,"INVALID_ARGUMENT","invalid fieldKey"); command(reactTag,promise) { it.clearEncodedField(fieldKey) } } + @ReactMethod override fun clearAllEncodedFields(reactTag: Double, promise: Promise) = command(reactTag,promise) { it.clearAllEncodedFields() } + @ReactMethod override fun setEncodedForecastTime(reactTag: Double, timestampMs: Double, promise: Promise) { if(!validSafeInteger(timestampMs,false)) return reject(promise,"INVALID_ARGUMENT","encoded forecast time must be safe integer"); command(reactTag,promise) { it.setEncodedForecastTime(timestampMs.toLong()) } } + @ReactMethod override fun setEncodedForecastPlaybackState(reactTag: Double, playback: String, promise: Promise) { if(playback != "PLAY" && playback != "PAUSE") return reject(promise,"INVALID_ARGUMENT","invalid encoded playback"); command(reactTag,promise) { it.setEncodedForecastPlaybackState(playback == "PLAY") } } + @ReactMethod override fun setEncodedForecastPlaybackSpeed(reactTag: Double, speed: Double, promise: Promise) { if(!speed.isFinite() || speed <= 0 || speed > Float.MAX_VALUE) return reject(promise,"INVALID_ARGUMENT","invalid encoded playback speed"); command(reactTag,promise) { it.setEncodedForecastPlaybackSpeed(speed.toFloat()) } } + @ReactMethod override fun setEncodedColorBinding(reactTag: Double, fieldKey: String, semanticID: Double, groupID: Double, expectedValueClass: String, expectedUnitID: Double, promise: Promise) { + if (!validEncodedId(fieldKey) || !validUInt16(semanticID, true) || !validUInt16(groupID, false) || !validUInt16(expectedUnitID, false)) return reject(promise,"INVALID_ARGUMENT","invalid encoded color binding selector") + val valueClass = when (expectedValueClass) { "SCALAR" -> 0; "DIRECTION_ANGLE" -> 2; else -> return reject(promise,"INVALID_ARGUMENT","invalid encoded color binding valueClass") } + command(reactTag,promise) { it.setEncodedColorBinding(fieldKey, semanticID.toInt(), groupID.toInt(), valueClass, expectedUnitID.toInt()) } + } + @ReactMethod override fun setEncodedParticleBinding(reactTag: Double, fieldKey: String, mode: String, uSemanticID: Double, uGroupID: Double, vSemanticID: Double, vGroupID: Double, directionSemanticID: Double, directionGroupID: Double, expectedUnitID: Double, promise: Promise) { + if (!validEncodedId(fieldKey) || !validUInt16(expectedUnitID, false)) return reject(promise,"INVALID_ARGUMENT","invalid encoded particle binding") + val vector = mode == "VECTOR_COMPONENTS" && validUInt16(uSemanticID, true) && validUInt16(uGroupID, false) && validUInt16(vSemanticID, true) && validUInt16(vGroupID, false) && inactiveSelector(directionSemanticID, directionGroupID) + val direction = mode == "DIRECTION_ANGLE" && inactiveSelector(uSemanticID, uGroupID) && inactiveSelector(vSemanticID, vGroupID) && validUInt16(directionSemanticID, true) && validUInt16(directionGroupID, false) + if (!vector && !direction) return reject(promise,"INVALID_ARGUMENT","active encoded particle selectors must be uint16 with non-zero semanticID; inactive slots are exactly zero") + command(reactTag,promise) { it.setEncodedParticleBinding(fieldKey, if (vector) 0 else 1, uSemanticID.toInt(), uGroupID.toInt(), vSemanticID.toInt(), vGroupID.toInt(), directionSemanticID.toInt(), directionGroupID.toInt(), expectedUnitID.toInt()) } + } @ReactMethod override fun setWeatherEnabled(reactTag: Double, enabled: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherEnabled(enabled) } @ReactMethod override fun setWeatherColorVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setWeatherColorVisible(visible) } @ReactMethod override fun setParticleVisible(reactTag: Double, visible: Boolean, promise: Promise) = command(reactTag, promise) { it.setParticleVisible(visible) } @@ -162,7 +186,7 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : } catch (error: IllegalStateException) { // WeatherRuntime rejects disabled/disposed commands by contract. Do not let an // exception escape the UI queue or turn a stale command into an implicit success. - reject(promise, "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the command") + reject(promise, if (error.message == "SOURCE_MISSING") "SOURCE_MISSING" else "NATIVE_REJECTED", error.message ?: "WeatherRuntime rejected the command") } } private fun floatCommand(reactTag: Double, value: Double, promise: Promise, fn: (WeatherRuntime, Float) -> WeatherRuntime.CommandResult) { @@ -270,6 +294,10 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : } private fun presence(exists: Boolean) = if (exists) "present" else "missing" + private fun validEncodedId(value: String) = value.isNotEmpty() && value.length <= 64 + private fun validUInt16(value: Double, positive: Boolean) = value.isFinite() && value == kotlin.math.floor(value) && value >= if (positive) 1 else 0 && value <= 0xffff + private fun inactiveSelector(semanticID: Double, groupID: Double) = semanticID == 0.0 && groupID == 0.0 + private fun validSafeInteger(value: Double, positive: Boolean) = value.isFinite() && value <= MAX_SAFE_INTEGER && value >= if (positive) 1 else 0 && value == kotlin.math.floor(value) private fun reject(promise: Promise, code: String, message: String) = promise.reject(code, message) private object WeatherIds { @@ -280,6 +308,8 @@ class WeatherRuntimeBridgeModule(private val context: ReactApplicationContext) : companion object { const val NAME = "WeatherRuntimeBridge" private const val MAX_METADATA_UTF8_BYTES = 1_048_576 + private const val MAX_CONTRACT_UTF8_BYTES = 256 * 1024 + private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0 private const val MAX_PAYLOAD_BYTES = 64L * 1024L * 1024L private const val DEFAULT_READ_BUFFER_BYTES = 16 * 1024 } diff --git a/packages/maplibre-particle-rn/src/FieldRuntime.ts b/packages/maplibre-particle-rn/src/FieldRuntime.ts index 9a7c46627..d6a5e71b2 100644 --- a/packages/maplibre-particle-rn/src/FieldRuntime.ts +++ b/packages/maplibre-particle-rn/src/FieldRuntime.ts @@ -1,5 +1,12 @@ import { getNativeFieldRuntimeBridge } from "./NativeWeatherRuntimeBridge"; -import type { ColorChannel, CommandResult, FieldColorStop, FieldPayloadRefs, FieldSubmitResult, FieldValueSource, PlaybackState, RuntimeSnapshot, TargetFps } from "./types"; +import type { ColorChannel, CommandResult, EncodedColorBindingInput, EncodedFieldRuntimeMetadata, EncodedFieldSubmitResult, EncodedParticleBindingInput, FieldColorStop, FieldPayloadRefs, FieldSubmitResult, FieldValueSource, PlaybackState, ResolvedFieldProfileContractInput, RuntimeSnapshot, TargetFps } from "./types"; + +const MAX_SAFE = Number.MAX_SAFE_INTEGER; +const MAX_ID_LENGTH = 64; +const MAX_CONTRACT_BYTES = 256 * 1024; +const encodedString = (value: string, name: string) => { if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID_LENGTH) throw new Error(`${name} must be a non-empty string of at most ${MAX_ID_LENGTH} characters`); }; +const uint = (value: number, name: string, maximum = MAX_SAFE, positive = false) => { if (!Number.isSafeInteger(value) || value < (positive ? 1 : 0) || value > maximum) throw new Error(`${name} must be a ${positive ? "positive" : "non-negative"} safe integer`); }; +function encodedContractJson(contract: ResolvedFieldProfileContractInput): string { encodedString(contract.profileID, "profileID"); uint(contract.contractSchemaVersion, "contractSchemaVersion", 0xffff, true); if (contract.contractSchemaVersion !== 1) throw new Error("unsupported contractSchemaVersion"); uint(contract.profileVersion, "profileVersion", 0xffffffff, true); if (!Array.isArray(contract.requiredPlanes) || contract.requiredPlanes.length === 0 || contract.requiredPlanes.length > 64) throw new Error("requiredPlanes must contain 1..64 entries"); for (const p of contract.requiredPlanes) { uint(p.semanticID, "semanticID", 0xffff, true); uint(p.unitID, "unitID", 0xffff); uint(p.groupID, "groupID", 0xffff); if (!p.requiredFlag || !["SCALAR", "VELOCITY_COMPONENT", "DIRECTION_ANGLE", "VALIDITY"].includes(p.valueClass) || !["NONE", "U_EAST", "V_NORTH", "W_UP"].includes(p.component)) throw new Error("invalid required plane contract"); } const json = JSON.stringify(contract); if (new TextEncoder().encode(json).length > MAX_CONTRACT_BYTES) throw new Error("contractJson exceeds 256 KiB"); return json; } function nativeBridge() { const bridge = getNativeFieldRuntimeBridge(); @@ -21,6 +28,14 @@ export const FieldRuntime = { payloadRefs.scalarFileUri ?? "", payloadRefs.vectorFileUri ?? "", ), + submitEncodedField: (reactTag: number, fieldKey: string, metadata: EncodedFieldRuntimeMetadata, contract: ResolvedFieldProfileContractInput, qbinFileUri: string): Promise => { encodedString(fieldKey, "fieldKey"); encodedString(metadata.fieldID, "fieldID"); uint(metadata.generation, "generation", MAX_SAFE, true); uint(metadata.dataVersion, "dataVersion"); if (typeof qbinFileUri !== "string" || !(qbinFileUri.startsWith("/") || qbinFileUri.startsWith("file://"))) return Promise.reject(new Error("qbinFileUri must be an absolute local path or file:// URI")); return nativeBridge().submitEncodedField(reactTag, fieldKey, metadata.fieldID, metadata.generation, metadata.dataVersion, encodedContractJson(contract), qbinFileUri); }, + setEncodedColorBinding: (reactTag: number, binding: EncodedColorBindingInput): Promise => { encodedString(binding.fieldKey, "fieldKey"); uint(binding.semanticID, "semanticID", 0xffff, true); uint(binding.groupID, "groupID", 0xffff); uint(binding.expectedUnitID, "expectedUnitID", 0xffff); if (!["SCALAR", "DIRECTION_ANGLE"].includes(binding.expectedValueClass)) return Promise.reject(new Error("invalid encoded color binding valueClass")); return nativeBridge().setEncodedColorBinding(reactTag, binding.fieldKey, binding.semanticID, binding.groupID, binding.expectedValueClass, binding.expectedUnitID); }, + setEncodedParticleBinding: (reactTag: number, binding: EncodedParticleBindingInput): Promise => { encodedString(binding.fieldKey, "fieldKey"); uint(binding.expectedUnitID, "expectedUnitID", 0xffff); const inactive = { semanticID: 0, groupID: 0 }; const valid = (plane: { semanticID: number; groupID: number }) => { uint(plane.semanticID, "selector semanticID", 0xffff, true); uint(plane.groupID, "selector groupID", 0xffff); }; let u = inactive, v = inactive, direction = inactive; if (binding.mode === "VECTOR_COMPONENTS" && binding.uPlane && binding.vPlane && !binding.directionPlane) { u = binding.uPlane; v = binding.vPlane; valid(u); valid(v); } else if (binding.mode === "DIRECTION_ANGLE" && binding.directionPlane && !binding.uPlane && !binding.vPlane) { direction = binding.directionPlane; valid(direction); } else return Promise.reject(new Error("encoded particle binding requires exactly U/V or direction selector; inactive ABI slots are zero")); return nativeBridge().setEncodedParticleBinding(reactTag, binding.fieldKey, binding.mode, u.semanticID, u.groupID, v.semanticID, v.groupID, direction.semanticID, direction.groupID, binding.expectedUnitID); }, + setEncodedForecastTime: (reactTag: number, timestampMs: number): Promise => { uint(timestampMs, "forecast timestamp"); return nativeBridge().setEncodedForecastTime(reactTag, timestampMs); }, + setEncodedForecastPlaybackState: (reactTag: number, playback: "PLAY" | "PAUSE"): Promise => nativeBridge().setEncodedForecastPlaybackState(reactTag, playback), + setEncodedForecastPlaybackSpeed: (reactTag: number, speed: number): Promise => !Number.isFinite(speed) || speed <= 0 ? Promise.reject(new Error("encoded playback speed must be finite and > 0")) : nativeBridge().setEncodedForecastPlaybackSpeed(reactTag, speed), + clearEncodedField: (reactTag: number, fieldKey: string): Promise => { encodedString(fieldKey, "fieldKey"); return nativeBridge().clearEncodedField(reactTag, fieldKey); }, + clearAllEncodedFields: (reactTag: number): Promise => nativeBridge().clearAllEncodedFields(reactTag), /** Explicit v1 compatibility only; v2 submitField always receives FieldPayloadRefs. */ submitLegacyVectorField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri), setFieldValueSource: (reactTag: number, source: FieldValueSource): Promise => nativeBridge().setFieldValueSource(reactTag, source), diff --git a/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts b/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts index 1387468fd..c473b62aa 100644 --- a/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts +++ b/packages/maplibre-particle-rn/src/NativeWeatherRuntimeBridge.ts @@ -6,6 +6,7 @@ type PlaybackState = "PLAY" | "PAUSE"; type TargetFps = "AUTO" | "FPS_30" | "FPS_60"; type CommandResult = "ACCEPTED" | "REJECTED"; type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN"; +type EncodedFieldSubmitResult = "ACCEPTED_INSERTED" | "ACCEPTED_REPLACED_REVISION" | "DUPLICATE" | "REJECTED_DECODE" | "REJECTED_CONTRACT" | "REJECTED_INVALID_FIELD_KEY" | "REJECTED_IDENTITY_CONFLICT" | "REJECTED_STALE_REVISION" | "REJECTED_INCOMPATIBLE" | "REJECTED_CAPACITY"; type ColorStop = { speedMps: number; red: number; green: number; blue: number; alpha: number }; type RuntimeSnapshot = { state: RuntimeState; playbackState: PlaybackState; targetFps: TargetFps; disposed: boolean }; export interface Spec extends TurboModule { @@ -18,6 +19,15 @@ export interface Spec extends TurboModule { // Keep the codegen ABI to primitive parameters. Empty scalar/vector strings are explicit // optional-plane markers at this boundary only; the public API keeps FieldPayloadRefs. submitField(reactTag: number, metadataJson: string, validityFileUri: string, scalarFileUri: string, vectorFileUri: string): Promise; + submitEncodedField(reactTag: number, fieldKey: string, fieldID: string, generation: number, dataVersion: number, contractJson: string, qbinFileUri: string): Promise; + setEncodedColorBinding(reactTag: number, fieldKey: string, semanticID: number, groupID: number, expectedValueClass: string, expectedUnitID: number): Promise; + // All selector slots are primitive for Codegen. Exactly (0,0) marks an inactive selector. + setEncodedParticleBinding(reactTag: number, fieldKey: string, mode: string, uSemanticID: number, uGroupID: number, vSemanticID: number, vGroupID: number, directionSemanticID: number, directionGroupID: number, expectedUnitID: number): Promise; + setEncodedForecastTime(reactTag: number, timestampMs: number): Promise; + setEncodedForecastPlaybackState(reactTag: number, playback: string): Promise; + setEncodedForecastPlaybackSpeed(reactTag: number, speed: number): Promise; + clearEncodedField(reactTag: number, fieldKey: string): Promise; + clearAllEncodedFields(reactTag: number): Promise; setFieldValueSource(reactTag: number, source: "SCALAR" | "VECTOR_MAGNITUDE"): Promise; setParticleAdvectionScale(reactTag: number, scale: number): Promise; setWeatherEnabled(reactTag: number, enabled: boolean): Promise; diff --git a/packages/maplibre-particle-rn/src/types.ts b/packages/maplibre-particle-rn/src/types.ts index 38f21b6de..de7b5bb82 100644 --- a/packages/maplibre-particle-rn/src/types.ts +++ b/packages/maplibre-particle-rn/src/types.ts @@ -3,6 +3,14 @@ export type PlaybackState = "PLAY" | "PAUSE"; export type TargetFps = "AUTO" | "FPS_30" | "FPS_60"; export type CommandResult = "ACCEPTED" | "REJECTED"; export type FieldSubmitResult = "REJECTED_SOURCE_MODE" | "REJECTED_ADAPTER" | "REJECTED_INCOMPATIBLE" | "REJECTED_NON_MONOTONIC" | "REJECTED_CAPACITY" | "ACCEPTED_PENDING_INITIAL_WINDOW" | "INITIAL_WINDOW_READY" | "ACCEPTED_NEXT_SNAPSHOT" | "DUPLICATE" | "UNKNOWN"; +/** Stable V4/Core outcome; unlike V3 rejection is a resolved result. */ +export type EncodedFieldSubmitResult = "ACCEPTED_INSERTED" | "ACCEPTED_REPLACED_REVISION" | "DUPLICATE" | "REJECTED_DECODE" | "REJECTED_CONTRACT" | "REJECTED_INVALID_FIELD_KEY" | "REJECTED_IDENTITY_CONFLICT" | "REJECTED_STALE_REVISION" | "REJECTED_INCOMPATIBLE" | "REJECTED_CAPACITY"; +export type EncodedFieldRuntimeMetadata = Readonly<{ fieldID: string; generation: number; dataVersion: number }>; +export type RequiredPlaneContractInput = Readonly<{ semanticID: number; valueClass: "SCALAR" | "VELOCITY_COMPONENT" | "DIRECTION_ANGLE" | "VALIDITY"; unitID: number; groupID: number; component: "NONE" | "U_EAST" | "V_NORTH" | "W_UP"; requiredFlag: boolean }>; +/** Frozen V4 JSON field names match ResolvedFieldProfileContract exactly. */ +export type ResolvedFieldProfileContractInput = Readonly<{ contractSchemaVersion: number; profileID: string; profileVersion: number; allowAdditionalPlanes: boolean; sharedValidityRequired: boolean; timeRelation: "ANY" | "VALID_GTE_REFERENCE" | "VALID_EQ_REFERENCE"; requiredPlanes: readonly RequiredPlaneContractInput[] }>; +export type EncodedColorBindingInput = Readonly<{ fieldKey: string; semanticID: number; groupID: number; expectedValueClass: "SCALAR" | "DIRECTION_ANGLE"; expectedUnitID: number }>; +export type EncodedParticleBindingInput = Readonly<{ fieldKey: string; mode: "VECTOR_COMPONENTS" | "DIRECTION_ANGLE"; uPlane?: Readonly<{ semanticID: number; groupID: number }>; vPlane?: Readonly<{ semanticID: number; groupID: number }>; directionPlane?: Readonly<{ semanticID: number; groupID: number }>; expectedUnitID: number }>; export type FieldPayloadRefs = Readonly< { validityFileUri: string } & ( | { scalarFileUri: string; vectorFileUri?: string } diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp index 723edda81..9cf76a58d 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.cpp @@ -36,6 +36,7 @@ #include #include #include +#include // Java -> C++ conversion #include "style/android_conversion.hpp" @@ -68,6 +69,8 @@ namespace mbgl { namespace android { +namespace {} + NativeMapView::NativeMapView(jni::JNIEnv& _env, const jni::Object& _obj, const jni::Object& jFileSource, @@ -1419,6 +1422,24 @@ jni::jboolean NativeMapView::controlRasterArraySourceTimeline(JNIEnv& env, return success; } +jni::jint NativeMapView::submitRasterArraySourceEncodedField(JNIEnv& env, const jni::String& sourceId, const jni::String& fieldKey, const jni::String& fieldId, jni::jlong generation, jni::jlong dataVersion, const jni::String& contractJson, const jni::Array& qbin) { + const auto id=jni::Make(env,sourceId); auto* source=dynamic_cast(map->getStyle().getSource(id)); + if(!source) return 0; // bridge-layer SOURCE_MISSING sentinel, never a Core result + if(generation<=0 || dataVersion<0 || !qbin) return static_cast(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedDecode); + const auto contract=mbgl::particle::parseResolvedFieldProfileContractJson(jni::Make(env,contractJson)); + if(!contract) return static_cast(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedContract); + std::vector bytes(qbin.Length(env)); if(!bytes.empty()) jni::GetArrayRegion(env,*qbin,0,bytes.size(),reinterpret_cast(bytes.data())); + const auto result=source->submitEncodedField(jni::Make(env,fieldKey), {jni::Make(env,fieldId),static_cast(generation),static_cast(dataVersion)}, *contract, bytes); + return static_cast(result); +} +jni::jint NativeMapView::clearRasterArraySourceEncodedField(JNIEnv& env,const jni::String& sourceId,const jni::String& fieldKey) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; return s->clearEncodedField(jni::Make(env,fieldKey)) ? 1 : -1; } +jni::jint NativeMapView::clearAllRasterArraySourceEncodedFields(JNIEnv& env,const jni::String& sourceId) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; s->clearAllEncodedFields(); return 1; } +jni::jint NativeMapView::setRasterArraySourceEncodedColorBinding(JNIEnv& env, const jni::String& sourceId, const jni::String& fieldKey, jni::jint semanticID, jni::jint groupID, jni::jint valueClass, jni::jint unitID) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; if(semanticID<=0||semanticID>UINT16_MAX||groupID<0||groupID>UINT16_MAX||unitID<0||unitID>UINT16_MAX||(valueClass!=0&&valueClass!=2))return -1; s->setColorBinding({jni::Make(env,fieldKey),{static_cast(semanticID),static_cast(groupID)},static_cast(valueClass),static_cast(unitID)}); return 1; } +jni::jint NativeMapView::setRasterArraySourceEncodedParticleBinding(JNIEnv& env, const jni::String& sourceId, const jni::String& fieldKey, jni::jint mode, jni::jint uSemanticID, jni::jint uGroupID, jni::jint vSemanticID, jni::jint vGroupID, jni::jint directionSemanticID, jni::jint directionGroupID, jni::jint unitID) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; const auto valid=[](jni::jint semantic,jni::jint group){return semantic>0&&semantic<=UINT16_MAX&&group>=0&&group<=UINT16_MAX;}; const auto inactive=[](jni::jint semantic,jni::jint group){return semantic==0&&group==0;}; if(unitID<0||unitID>UINT16_MAX)return -1; mbgl::particle::ParticleBinding binding; binding.fieldKey=jni::Make(env,fieldKey); binding.expectedUnitID=static_cast(unitID); if(mode==0&&valid(uSemanticID,uGroupID)&&valid(vSemanticID,vGroupID)&&inactive(directionSemanticID,directionGroupID)){binding.mode=mbgl::particle::ParticleBindingMode::VectorComponents;binding.uPlane={static_cast(uSemanticID),static_cast(uGroupID)};binding.vPlane={static_cast(vSemanticID),static_cast(vGroupID)};} else if(mode==1&&inactive(uSemanticID,uGroupID)&&inactive(vSemanticID,vGroupID)&&valid(directionSemanticID,directionGroupID)){binding.mode=mbgl::particle::ParticleBindingMode::DirectionAngle;binding.directionPlane={static_cast(directionSemanticID),static_cast(directionGroupID)};} else return -1; s->setParticleBinding(std::move(binding)); return 1; } +jni::jint NativeMapView::setRasterArraySourceEncodedForecastTime(JNIEnv& env,const jni::String& sourceId,jni::jlong timestamp) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; return timestamp>=0&&s->setRequestedForecastTime(static_cast(timestamp))?1:-1; } +jni::jint NativeMapView::setRasterArraySourceEncodedForecastPlaybackState(JNIEnv& env,const jni::String& sourceId,jni::jboolean playing) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; s->setForecastPlaybackState(playing ? mbgl::particle::ForecastPlaybackState::Playing : mbgl::particle::ForecastPlaybackState::Paused); return 1; } +jni::jint NativeMapView::setRasterArraySourceEncodedForecastPlaybackSpeed(JNIEnv& env,const jni::String& sourceId,jni::jfloat speed) { auto* s=dynamic_cast(map->getStyle().getSource(jni::Make(env,sourceId))); if(!s)return 0; return s->setForecastPlaybackSpeed(speed)?1:-1; } + void NativeMapView::addLayer(JNIEnv& env, jlong nativeLayerPtr, const jni::String& before) { assert(nativeLayerPtr != 0); @@ -1824,6 +1845,14 @@ void NativeMapView::registerNative(jni::JNIEnv& env) { METHOD(&NativeMapView::clearRasterArraySourceHostFields, "nativeClearRasterArraySourceHostFields"), METHOD(&NativeMapView::seekRasterArraySourceTimelineToTimestamp, "nativeSeekRasterArraySourceTimelineToTimestamp"), METHOD(&NativeMapView::controlRasterArraySourceTimeline, "nativeControlRasterArraySourceTimeline"), + METHOD(&NativeMapView::submitRasterArraySourceEncodedField, "nativeSubmitRasterArraySourceEncodedField"), + METHOD(&NativeMapView::clearRasterArraySourceEncodedField, "nativeClearRasterArraySourceEncodedField"), + METHOD(&NativeMapView::clearAllRasterArraySourceEncodedFields, "nativeClearAllRasterArraySourceEncodedFields"), + METHOD(&NativeMapView::setRasterArraySourceEncodedColorBinding, "nativeSetRasterArraySourceEncodedColorBinding"), + METHOD(&NativeMapView::setRasterArraySourceEncodedParticleBinding, "nativeSetRasterArraySourceEncodedParticleBinding"), + METHOD(&NativeMapView::setRasterArraySourceEncodedForecastTime, "nativeSetRasterArraySourceEncodedForecastTime"), + METHOD(&NativeMapView::setRasterArraySourceEncodedForecastPlaybackState, "nativeSetRasterArraySourceEncodedForecastPlaybackState"), + METHOD(&NativeMapView::setRasterArraySourceEncodedForecastPlaybackSpeed, "nativeSetRasterArraySourceEncodedForecastPlaybackSpeed"), METHOD(&NativeMapView::addLayer, "nativeAddLayer"), METHOD(&NativeMapView::addLayerAbove, "nativeAddLayerAbove"), METHOD(&NativeMapView::addLayerAt, "nativeAddLayerAt"), diff --git a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp index c9002320c..9ea178e80 100644 --- a/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp +++ b/platform/android/MapLibreAndroid/src/cpp/native_map_view.hpp @@ -310,6 +310,14 @@ public: jni::jboolean clearRasterArraySourceHostFields(JNIEnv&, const jni::String&); jni::jboolean seekRasterArraySourceTimelineToTimestamp(JNIEnv&, const jni::String&, jni::jlong); jni::jboolean controlRasterArraySourceTimeline(JNIEnv&, const jni::String&, jni::jint, jni::jfloat); + jni::jint submitRasterArraySourceEncodedField(JNIEnv&, const jni::String&, const jni::String&, const jni::String&, jni::jlong, jni::jlong, const jni::String&, const jni::Array&); + jni::jint clearRasterArraySourceEncodedField(JNIEnv&, const jni::String&, const jni::String&); + jni::jint clearAllRasterArraySourceEncodedFields(JNIEnv&, const jni::String&); + jni::jint setRasterArraySourceEncodedColorBinding(JNIEnv&, const jni::String&, const jni::String&, jni::jint, jni::jint, jni::jint, jni::jint); + jni::jint setRasterArraySourceEncodedParticleBinding(JNIEnv&, const jni::String&, const jni::String&, jni::jint, jni::jint, jni::jint, jni::jint, jni::jint, jni::jint, jni::jint); + jni::jint setRasterArraySourceEncodedForecastTime(JNIEnv&, const jni::String&, jni::jlong); + jni::jint setRasterArraySourceEncodedForecastPlaybackState(JNIEnv&, const jni::String&, jni::jboolean); + jni::jint setRasterArraySourceEncodedForecastPlaybackSpeed(JNIEnv&, const jni::String&, jni::jfloat); void addLayer(JNIEnv&, jlong, const jni::String&); 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 ba5c58a13..2c31848ac 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 @@ -425,6 +425,16 @@ public final class MapLibreMap { return nativeMapView.controlRasterArraySourceTimeline(sourceId, command, value); } + /** V4-only QBIN ingress. It never enters the V3 runtime timeline. */ + public int submitRasterArraySourceEncodedField(String sourceId, String fieldKey, String fieldId, long generation, long dataVersion, String contractJson, byte[] qbin) { return nativeMapView.submitRasterArraySourceEncodedField(sourceId, fieldKey, fieldId, generation, dataVersion, contractJson, qbin); } + public int clearRasterArraySourceEncodedField(String sourceId, String fieldKey) { return nativeMapView.clearRasterArraySourceEncodedField(sourceId, fieldKey); } + public int clearAllRasterArraySourceEncodedFields(String sourceId) { return nativeMapView.clearAllRasterArraySourceEncodedFields(sourceId); } + public int setRasterArraySourceEncodedColorBinding(String sourceId, String fieldKey, int semanticID, int groupID, int valueClass, int unitID) { return nativeMapView.setRasterArraySourceEncodedColorBinding(sourceId, fieldKey, semanticID, groupID, valueClass, unitID); } + public int setRasterArraySourceEncodedParticleBinding(String sourceId, String fieldKey, int mode, int uSemanticID, int uGroupID, int vSemanticID, int vGroupID, int directionSemanticID, int directionGroupID, int unitID) { return nativeMapView.setRasterArraySourceEncodedParticleBinding(sourceId, fieldKey, mode, uSemanticID, uGroupID, vSemanticID, vGroupID, directionSemanticID, directionGroupID, unitID); } + public int setRasterArraySourceEncodedForecastTime(String sourceId, long timestamp) { return nativeMapView.setRasterArraySourceEncodedForecastTime(sourceId, timestamp); } + public int setRasterArraySourceEncodedForecastPlaybackState(String sourceId, boolean playing) { return nativeMapView.setRasterArraySourceEncodedForecastPlaybackState(sourceId, playing); } + public int setRasterArraySourceEncodedForecastPlaybackSpeed(String sourceId, float speed) { return nativeMapView.setRasterArraySourceEncodedForecastPlaybackSpeed(sourceId, speed); } + public static final int HOST_FIELD_REJECTED_SOURCE_MODE = -1; public static final int HOST_FIELD_REJECTED_ADAPTER = -2; public static final int HOST_FIELD_REJECTED_INCOMPATIBLE = -3; diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java index e3c868a9f..8a5df6a6a 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMap.java @@ -177,6 +177,15 @@ interface NativeMap { boolean controlRasterArraySourceTimeline(String sourceId, int command, float value); + int submitRasterArraySourceEncodedField(String sourceId, String fieldKey, String fieldId, long generation, long dataVersion, String contractJson, byte[] qbin); + int clearRasterArraySourceEncodedField(String sourceId, String fieldKey); + int clearAllRasterArraySourceEncodedFields(String sourceId); + int setRasterArraySourceEncodedColorBinding(String sourceId, String fieldKey, int semanticID, int groupID, int valueClass, int unitID); + int setRasterArraySourceEncodedParticleBinding(String sourceId, String fieldKey, int mode, int uSemanticID, int uGroupID, int vSemanticID, int vGroupID, int directionSemanticID, int directionGroupID, int unitID); + int setRasterArraySourceEncodedForecastTime(String sourceId, long timestamp); + int setRasterArraySourceEncodedForecastPlaybackState(String sourceId, boolean playing); + int setRasterArraySourceEncodedForecastPlaybackSpeed(String sourceId, float speed); + boolean removeLayer(@NonNull String layerId); boolean removeLayer(@NonNull Layer layer); diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java index 4df639ed7..64c29f6f7 100755 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/NativeMapView.java @@ -1047,6 +1047,15 @@ final class NativeMapView implements NativeMap { && nativeControlRasterArraySourceTimeline(sourceId, command, value); } + @Override public int submitRasterArraySourceEncodedField(String sourceId, String fieldKey, String fieldId, long generation, long dataVersion, String contractJson, byte[] qbin) { return checkState("submitRasterArraySourceEncodedField") ? 0 : nativeSubmitRasterArraySourceEncodedField(sourceId, fieldKey, fieldId, generation, dataVersion, contractJson, qbin); } + @Override public int clearRasterArraySourceEncodedField(String sourceId, String fieldKey) { return checkState("clearRasterArraySourceEncodedField") ? 0 : nativeClearRasterArraySourceEncodedField(sourceId, fieldKey); } + @Override public int clearAllRasterArraySourceEncodedFields(String sourceId) { return checkState("clearAllRasterArraySourceEncodedFields") ? 0 : nativeClearAllRasterArraySourceEncodedFields(sourceId); } + @Override public int setRasterArraySourceEncodedColorBinding(String sourceId, String fieldKey, int semanticID, int groupID, int valueClass, int unitID) { return checkState("setRasterArraySourceEncodedColorBinding") ? 0 : nativeSetRasterArraySourceEncodedColorBinding(sourceId, fieldKey, semanticID, groupID, valueClass, unitID); } + @Override public int setRasterArraySourceEncodedParticleBinding(String sourceId, String fieldKey, int mode, int uSemanticID, int uGroupID, int vSemanticID, int vGroupID, int directionSemanticID, int directionGroupID, int unitID) { return checkState("setRasterArraySourceEncodedParticleBinding") ? 0 : nativeSetRasterArraySourceEncodedParticleBinding(sourceId, fieldKey, mode, uSemanticID, uGroupID, vSemanticID, vGroupID, directionSemanticID, directionGroupID, unitID); } + @Override public int setRasterArraySourceEncodedForecastTime(String sourceId, long timestamp) { return checkState("setRasterArraySourceEncodedForecastTime") ? 0 : nativeSetRasterArraySourceEncodedForecastTime(sourceId, timestamp); } + @Override public int setRasterArraySourceEncodedForecastPlaybackState(String sourceId, boolean playing) { return checkState("setRasterArraySourceEncodedForecastPlaybackState") ? 0 : nativeSetRasterArraySourceEncodedForecastPlaybackState(sourceId, playing); } + @Override public int setRasterArraySourceEncodedForecastPlaybackSpeed(String sourceId, float speed) { return checkState("setRasterArraySourceEncodedForecastPlaybackSpeed") ? 0 : nativeSetRasterArraySourceEncodedForecastPlaybackSpeed(sourceId, speed); } + @Override public void addLayer(@NonNull Layer layer) { if (checkState("addLayer")) { @@ -1822,6 +1831,14 @@ final class NativeMapView implements NativeMap { @Keep private native boolean nativeControlRasterArraySourceTimeline(String sourceId, int command, float value); + @Keep private native int nativeSubmitRasterArraySourceEncodedField(String sourceId, String fieldKey, String fieldId, long generation, long dataVersion, String contractJson, byte[] qbin); + @Keep private native int nativeClearRasterArraySourceEncodedField(String sourceId, String fieldKey); + @Keep private native int nativeClearAllRasterArraySourceEncodedFields(String sourceId); + @Keep private native int nativeSetRasterArraySourceEncodedColorBinding(String sourceId, String fieldKey, int semanticID, int groupID, int valueClass, int unitID); + @Keep private native int nativeSetRasterArraySourceEncodedParticleBinding(String sourceId, String fieldKey, int mode, int uSemanticID, int uGroupID, int vSemanticID, int vGroupID, int directionSemanticID, int directionGroupID, int unitID); + @Keep private native int nativeSetRasterArraySourceEncodedForecastTime(String sourceId, long timestamp); + @Keep private native int nativeSetRasterArraySourceEncodedForecastPlaybackState(String sourceId, boolean playing); + @Keep private native int nativeSetRasterArraySourceEncodedForecastPlaybackSpeed(String sourceId, float speed); @Keep private native void nativeAddLayer(long layerPtr, String before) throws CannotAddLayerException; diff --git a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java index 6237c5fc5..eea203b45 100644 --- a/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java +++ b/platform/android/MapLibreAndroid/src/main/java/org/maplibre/android/maps/WeatherRuntime.java @@ -27,6 +27,11 @@ public final class WeatherRuntime { public boolean accepted() { return code > 0; } } public enum CommandResult { ACCEPTED, REJECTED } + public enum EncodedFieldSubmitResult { + ACCEPTED_INSERTED(1), ACCEPTED_REPLACED_REVISION(2), DUPLICATE(3), REJECTED_DECODE(-1), REJECTED_CONTRACT(-2), REJECTED_INVALID_FIELD_KEY(-3), REJECTED_IDENTITY_CONFLICT(-4), REJECTED_STALE_REVISION(-5), REJECTED_INCOMPATIBLE(-6), REJECTED_CAPACITY(-7); + final int code; EncodedFieldSubmitResult(int code) { this.code = code; } + static EncodedFieldSubmitResult fromCode(int code) { for (EncodedFieldSubmitResult v : values()) if (v.code == code) return v; throw new IllegalArgumentException("unknown encoded submit code"); } + } public static final class Config { public final String sourceId; @@ -78,6 +83,28 @@ public final class WeatherRuntime { if (state == State.READY && result.accepted()) scheduleCadenceWake(); return result; } + /** Isolated V4 ingress: it deliberately does not mutate V3 state/cadence/timeline. */ + public EncodedFieldSubmitResult submitEncodedField(@NonNull String fieldKey, @NonNull String fieldId, long generation, long dataVersion, @NonNull String contractJson, @NonNull byte[] qbin) { + requireAttached(); requireEnabled(); + if (fieldKey.isEmpty() || fieldId.isEmpty() || generation <= 0 || dataVersion < 0 || contractJson.isEmpty() || qbin.length == 0) throw new IllegalArgumentException("invalid encoded field argument"); + int code = map.submitRasterArraySourceEncodedField(config.sourceId, fieldKey, fieldId, generation, dataVersion, contractJson, qbin); + if (code == 0) throw new IllegalStateException("SOURCE_MISSING"); + return EncodedFieldSubmitResult.fromCode(code); + } + public CommandResult clearEncodedField(@NonNull String fieldKey) { requireAttached(); return fieldKey.isEmpty() ? CommandResult.REJECTED : encodedCommand(map.clearRasterArraySourceEncodedField(config.sourceId, fieldKey)); } + public CommandResult clearAllEncodedFields() { requireAttached(); return encodedCommand(map.clearAllRasterArraySourceEncodedFields(config.sourceId)); } + /** V4 binding intent; unit and all mode selectors reach Core unchanged. */ + public CommandResult setEncodedColorBinding(@NonNull String fieldKey, int semanticID, int groupID, int valueClass, int expectedUnitID) { + requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedColorBinding(config.sourceId, fieldKey, semanticID, groupID, valueClass, expectedUnitID)); + } + /** ABI reserves all selector slots: inactive slots must be exactly (0, 0). */ + public CommandResult setEncodedParticleBinding(@NonNull String fieldKey, int mode, int uSemanticID, int uGroupID, int vSemanticID, int vGroupID, int directionSemanticID, int directionGroupID, int expectedUnitID) { + requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedParticleBinding(config.sourceId, fieldKey, mode, uSemanticID, uGroupID, vSemanticID, vGroupID, directionSemanticID, directionGroupID, expectedUnitID)); + } + public CommandResult setEncodedForecastTime(long timestamp) { requireAttached(); return timestamp < 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastTime(config.sourceId, timestamp)); } + /** State and speed intentionally remain independent V4 Core commands. */ + public CommandResult setEncodedForecastPlaybackState(boolean playing) { requireAttached(); return encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackState(config.sourceId, playing)); } + public CommandResult setEncodedForecastPlaybackSpeed(float speed) { requireAttached(); return !Float.isFinite(speed) || speed <= 0 ? CommandResult.REJECTED : encodedCommand(map.setRasterArraySourceEncodedForecastPlaybackSpeed(config.sourceId, speed)); } /** v2 provider-neutral ingress; missing scalar/vector is expressed only by its explicit presence flag. */ public FieldSubmitResult submitField(@NonNull String metadataJson, @Nullable byte[] scalarBytes, @Nullable byte[] vectorBytes, @NonNull byte[] validityBytes) { requireAttached(); requireEnabled(); @@ -170,6 +197,8 @@ public final class WeatherRuntime { return result(map.setWeatherColorStops(config.weatherColorLayerId, speeds, colors)); } private static CommandResult result(boolean accepted) { return accepted ? CommandResult.ACCEPTED : CommandResult.REJECTED; } + /** JNI 0 is private source/type lifecycle sentinel, never a Core V4 result. */ + private static CommandResult encodedCommand(int code) { if (code == 0) throw new IllegalStateException("SOURCE_MISSING"); return code > 0 ? CommandResult.ACCEPTED : CommandResult.REJECTED; } public enum FieldValueSource { SCALAR(0f), VECTOR_MAGNITUDE(1f); final float code; FieldValueSource(float code) { this.code = code; } } private boolean shouldWakeWeather() { return !disposed && targetFps != TargetFps.AUTO && state == State.READY && (particleVisible || (weatherColorVisible && playbackState == PlaybackState.PLAY)); diff --git a/src/mbgl/particle/field/field_profile_contract_json.cpp b/src/mbgl/particle/field/field_profile_contract_json.cpp new file mode 100644 index 000000000..62cd6708e --- /dev/null +++ b/src/mbgl/particle/field/field_profile_contract_json.cpp @@ -0,0 +1,97 @@ +#include + +#include + +#include +#include + +namespace mbgl::particle { +namespace { +constexpr std::size_t MaxContractBytes = 256u * 1024u; +constexpr std::size_t MaxProfileIDBytes = 64u; +constexpr std::size_t MaxRequiredPlanes = 64u; + +bool uintMember(const rapidjson::Value& value, const char* name, uint64_t maximum, uint64_t& out) { + const auto member = value.FindMember(name); + if (member == value.MemberEnd() || !member->value.IsUint64() || member->value.GetUint64() > maximum) return false; + out = member->value.GetUint64(); + return true; +} +bool boolMember(const rapidjson::Value& value, const char* name, bool& out) { + const auto member = value.FindMember(name); + if (member == value.MemberEnd() || !member->value.IsBool()) return false; + out = member->value.GetBool(); + return true; +} +bool stringMember(const rapidjson::Value& value, const char* name, std::string& out) { + const auto member = value.FindMember(name); + if (member == value.MemberEnd() || !member->value.IsString()) return false; + out.assign(member->value.GetString(), member->value.GetStringLength()); + return true; +} +std::optional valueClass(const std::string& value) { + if (value == "SCALAR") return FieldValueClass::Scalar; + if (value == "VELOCITY_COMPONENT") return FieldValueClass::VelocityComponent; + if (value == "DIRECTION_ANGLE") return FieldValueClass::DirectionAngle; + if (value == "VALIDITY") return FieldValueClass::Validity; + return std::nullopt; +} +std::optional component(const std::string& value) { + if (value == "NONE") return FieldComponent::None; + if (value == "U_EAST") return FieldComponent::UEast; + if (value == "V_NORTH") return FieldComponent::VNorth; + if (value == "W_UP") return FieldComponent::WUp; + return std::nullopt; +} +std::optional timeRelation(const std::string& value) { + if (value == "ANY") return FieldTimeRelation::Any; + if (value == "VALID_GTE_REFERENCE") return FieldTimeRelation::ValidGteReference; + if (value == "VALID_EQ_REFERENCE") return FieldTimeRelation::ValidEqReference; + return std::nullopt; +} +} // namespace + +std::optional parseResolvedFieldProfileContractJson(const std::string& json) { + if (json.empty() || json.size() > MaxContractBytes) return std::nullopt; + rapidjson::Document document; + document.Parse(json.data(), json.size()); + if (document.HasParseError() || !document.IsObject()) return std::nullopt; + + uint64_t schema = 0, profileVersion = 0; + std::string profileID, relation; + if (!uintMember(document, "contractSchemaVersion", 1, schema) || schema != 1 || + !uintMember(document, "profileVersion", std::numeric_limits::max(), profileVersion) || + !stringMember(document, "profileID", profileID) || profileID.empty() || profileID.size() > MaxProfileIDBytes || + !stringMember(document, "timeRelation", relation)) return std::nullopt; + const auto planes = document.FindMember("requiredPlanes"); + if (planes == document.MemberEnd() || !planes->value.IsArray() || planes->value.Empty() || + planes->value.Size() > MaxRequiredPlanes) return std::nullopt; + + ResolvedFieldProfileContract result; + result.contractSchemaVersion = static_cast(schema); + result.profileID = std::move(profileID); + result.profileVersion = static_cast(profileVersion); + if (!boolMember(document, "allowAdditionalPlanes", result.allowAdditionalPlanes) || + !boolMember(document, "sharedValidityRequired", result.sharedValidityRequired)) return std::nullopt; + const auto parsedRelation = timeRelation(relation); + if (!parsedRelation) return std::nullopt; + result.timeRelation = *parsedRelation; + result.requiredPlanes.reserve(planes->value.Size()); + for (const auto& plane : planes->value.GetArray()) { + uint64_t semanticID = 0, unitID = 0, groupID = 0; + bool requiredFlag = false; + std::string className, componentName; + if (!plane.IsObject() || !uintMember(plane, "semanticID", UINT16_MAX, semanticID) || semanticID == 0 || + !uintMember(plane, "unitID", UINT16_MAX, unitID) || !uintMember(plane, "groupID", UINT16_MAX, groupID) || + !boolMember(plane, "requiredFlag", requiredFlag) || !requiredFlag || + !stringMember(plane, "valueClass", className) || !stringMember(plane, "component", componentName)) return std::nullopt; + const auto parsedClass = valueClass(className); + const auto parsedComponent = component(componentName); + if (!parsedClass || !parsedComponent) return std::nullopt; + result.requiredPlanes.push_back({static_cast(semanticID), *parsedClass, static_cast(unitID), + static_cast(groupID), *parsedComponent, requiredFlag}); + } + return result.isStructurallyValid() ? std::optional(std::move(result)) : std::nullopt; +} + +} // namespace mbgl::particle diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 11894e941..4424e7a4b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -20,6 +20,7 @@ add_library( ${PROJECT_SOURCE_DIR}/test/platform/settings.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_runtime.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/field_binding.test.cpp + ${PROJECT_SOURCE_DIR}/test/particle/field_profile_contract_json.test.cpp ${PROJECT_SOURCE_DIR}/test/particle/qbin_v2_decoder.test.cpp ${PROJECT_SOURCE_DIR}/test/plugin/plugin.test.cpp ${PROJECT_SOURCE_DIR}/test/renderer/image_manager.test.cpp diff --git a/test/particle/field_profile_contract_json.test.cpp b/test/particle/field_profile_contract_json.test.cpp new file mode 100644 index 000000000..4e6130eef --- /dev/null +++ b/test/particle/field_profile_contract_json.test.cpp @@ -0,0 +1,36 @@ +#include + +#include + +namespace mbgl::particle { +namespace { +std::string valid() { + return R"({"contractSchemaVersion":1,"profileID":"test.wind","profileVersion":1,"allowAdditionalPlanes":false,"sharedValidityRequired":true,"timeRelation":"ANY","requiredPlanes":[{"semanticID":1,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"U_EAST","requiredFlag":true},{"semanticID":2,"valueClass":"VELOCITY_COMPONENT","unitID":1,"groupID":1,"component":"V_NORTH","requiredFlag":true},{"semanticID":3,"valueClass":"VALIDITY","unitID":0,"groupID":0,"component":"NONE","requiredFlag":true}]})"; +} +TEST(ParticleFieldProfileContractJson, ParsesFrozenSchema) { + const auto contract = parseResolvedFieldProfileContractJson(valid()); + ASSERT_TRUE(contract); EXPECT_EQ(contract->contractSchemaVersion, 1); EXPECT_EQ(contract->profileID, "test.wind"); + EXPECT_EQ(contract->requiredPlanes.size(), 3u); EXPECT_FALSE(contract->allowAdditionalPlanes); +} +TEST(ParticleFieldProfileContractJson, RejectsMissingFrozenFieldsAndUnknownSchema) { + EXPECT_FALSE(parseResolvedFieldProfileContractJson(R"({"contractSchemaVersion":1})")); + auto json = valid(); json.replace(json.find("\"contractSchemaVersion\":1"), 25, "\"contractSchemaVersion\":2"); + EXPECT_FALSE(parseResolvedFieldProfileContractJson(json)); +} +TEST(ParticleFieldProfileContractJson, RejectsOutOfRangeDuplicateAndInvalidEnums) { + auto overflow = valid(); overflow.replace(overflow.find("\"semanticID\":1"), 14, "\"semanticID\":65536"); + EXPECT_FALSE(parseResolvedFieldProfileContractJson(overflow)); + auto duplicate = valid(); duplicate.replace(duplicate.find("\"semanticID\":2"), 14, "\"semanticID\":1"); + EXPECT_FALSE(parseResolvedFieldProfileContractJson(duplicate)); + auto invalidEnum = valid(); invalidEnum.replace(invalidEnum.find("\"timeRelation\":\"ANY\""), 20, "\"timeRelation\":\"LATER\""); + EXPECT_FALSE(parseResolvedFieldProfileContractJson(invalidEnum)); +} +TEST(ParticleFieldProfileContractJson, RejectsPlaneLimitAndInvalidProfileID) { + auto invalidID = valid(); invalidID.replace(invalidID.find("test.wind"), 9, ""); + EXPECT_FALSE(parseResolvedFieldProfileContractJson(invalidID)); + std::string planes; for (int i = 0; i != 65; ++i) { if (!planes.empty()) planes += ','; planes += R"({"semanticID":99,"valueClass":"SCALAR","unitID":0,"groupID":)" + std::to_string(i) + R"(,"component":"NONE","requiredFlag":true})"; } + const auto tooMany = std::string(R"({"contractSchemaVersion":1,"profileID":"x","profileVersion":1,"allowAdditionalPlanes":true,"sharedValidityRequired":true,"timeRelation":"ANY","requiredPlanes":[)") + planes + "]}"; + EXPECT_FALSE(parseResolvedFieldProfileContractJson(tooMany)); +} +} // namespace +} // namespace mbgl::particle diff --git a/粒子开发记录.md b/粒子开发记录.md index d0af7bc71..f97f968de 100644 --- a/粒子开发记录.md +++ b/粒子开发记录.md @@ -772,3 +772,10 @@ Android V3-08 单次综合验收已完成并关闭。Native `13.4.1-particle.2` - 已按实际 RN TurboModule、Kotlin、WeatherRuntime、MapLibreMap、NativeMapView JNI 与 RasterArraySource V4 API 完成只读调查;未修改 production code、Test Host、Renderer/GPU,也未下载真实 QBIN 或运行 Android 构建。 - 结论:V4 必须是独立 encoded-field API。现有 V3 `submitField`、`clearFields`、`setForecastTime`、playback 会操作 V3 Host field/timeline,绝不能复用。RN 应传本地 QBIN URI,Kotlin 使用既有 64 MiB guarded read,Java `byte[]` / JNI copy 是可复用的 binary seam;禁止 JS number[]/Base64。 - 建议 V4 结果逐项映射 Core `RasterArrayEncodedFieldSubmitResult`,桥接层 file/lifecycle/style/source 错误用独立 rejection code;每次 command 都沿用 Fabric tag -> current MLRNMapView -> MapLibreMap 的 UI queue late lookup,不建立第二 registry 或缓存 native pointer。 + +### 2026-09-13 STEP-078 QBIN2 V4 Android / JNI / RN 桥接实现 v2 + +- 完成隔离 V4 RN/TurboModule/Kotlin/Java/JNI/Core Host 调用链;QBIN 只经受 1..64 MiB 限制的本地 file URI 传输。十种 Core submit result 保持逐项 resolve,source/type 生命周期错误只经私有 sentinel 转为明确 rejection。 +- frozen contract schema 改为 Core 同名 `contractSchemaVersion` / `requiredFlag` 等字段,并抽取为可 Linux 测试的 provider-neutral C++ parser;新增合法与所有指定 fail-closed case 单测。 +- Color binding 端到端传递 expected unit;Particle binding 固化 mode-aware U/V/direction slots,inactive slot 仅 `(0,0)`;forecast state/speed 为完全独立 Core command。 +- Linux 限流 runner build、parser/Source V4/Binding/QBIN/Runtime/V3 Source 回归、TypeScript、TurboModule codegen、JNI declaration-registration 静态核对和 `git diff --check` 均 PASS。Android Gradle/APK/ADB/JNI 真实编译、Jest、iOS/Metal 未执行,等待后续一次性 Android 验收;未进入 STEP-079。