feat: complete STEP-078 V4 bridge
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled

This commit is contained in:
OpenAI Codex
2026-09-13 16:10:49 +08:00
parent 4b5192e224
commit 73779b89bc
18 changed files with 403 additions and 2 deletions

View File

@@ -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

View File

@@ -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。

View File

@@ -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。

View File

@@ -0,0 +1,14 @@
#pragma once
#include <mbgl/particle/field/field_profile_contract.hpp>
#include <optional>
#include <string>
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<ResolvedFieldProfileContract> parseResolvedFieldProfileContractJson(const std::string& json);
} // namespace mbgl::particle

View File

@@ -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
}

View File

@@ -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<EncodedFieldSubmitResult> => { 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<CommandResult> => { 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<CommandResult> => { 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<CommandResult> => { uint(timestampMs, "forecast timestamp"); return nativeBridge().setEncodedForecastTime(reactTag, timestampMs); },
setEncodedForecastPlaybackState: (reactTag: number, playback: "PLAY" | "PAUSE"): Promise<CommandResult> => nativeBridge().setEncodedForecastPlaybackState(reactTag, playback),
setEncodedForecastPlaybackSpeed: (reactTag: number, speed: number): Promise<CommandResult> => !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<CommandResult> => { encodedString(fieldKey, "fieldKey"); return nativeBridge().clearEncodedField(reactTag, fieldKey); },
clearAllEncodedFields: (reactTag: number): Promise<CommandResult> => nativeBridge().clearAllEncodedFields(reactTag),
/** Explicit v1 compatibility only; v2 submitField always receives FieldPayloadRefs. */
submitLegacyVectorField: (reactTag: number, metadataJson: string, payloadFileUri: string): Promise<FieldSubmitResult> => nativeBridge().submitWindField(reactTag, metadataJson, payloadFileUri),
setFieldValueSource: (reactTag: number, source: FieldValueSource): Promise<CommandResult> => nativeBridge().setFieldValueSource(reactTag, source),

View File

@@ -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<FieldSubmitResult>;
submitEncodedField(reactTag: number, fieldKey: string, fieldID: string, generation: number, dataVersion: number, contractJson: string, qbinFileUri: string): Promise<EncodedFieldSubmitResult>;
setEncodedColorBinding(reactTag: number, fieldKey: string, semanticID: number, groupID: number, expectedValueClass: string, expectedUnitID: number): Promise<CommandResult>;
// 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<CommandResult>;
setEncodedForecastTime(reactTag: number, timestampMs: number): Promise<CommandResult>;
setEncodedForecastPlaybackState(reactTag: number, playback: string): Promise<CommandResult>;
setEncodedForecastPlaybackSpeed(reactTag: number, speed: number): Promise<CommandResult>;
clearEncodedField(reactTag: number, fieldKey: string): Promise<CommandResult>;
clearAllEncodedFields(reactTag: number): Promise<CommandResult>;
setFieldValueSource(reactTag: number, source: "SCALAR" | "VECTOR_MAGNITUDE"): Promise<CommandResult>;
setParticleAdvectionScale(reactTag: number, scale: number): Promise<CommandResult>;
setWeatherEnabled(reactTag: number, enabled: boolean): Promise<CommandResult>;

View File

@@ -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 }

View File

@@ -36,6 +36,7 @@
#include <mbgl/style/image.hpp>
#include <mbgl/style/filter.hpp>
#include <mbgl/renderer/query.hpp>
#include <mbgl/particle/field/field_profile_contract_json.hpp>
// 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<NativeMapView>& _obj,
const jni::Object<FileSource>& 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<jbyte>& qbin) {
const auto id=jni::Make<std::string>(env,sourceId); auto* source=dynamic_cast<mbgl::style::RasterArraySource*>(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<jni::jint>(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedDecode);
const auto contract=mbgl::particle::parseResolvedFieldProfileContractJson(jni::Make<std::string>(env,contractJson));
if(!contract) return static_cast<jni::jint>(mbgl::style::RasterArrayEncodedFieldSubmitResult::RejectedContract);
std::vector<uint8_t> bytes(qbin.Length(env)); if(!bytes.empty()) jni::GetArrayRegion(env,*qbin,0,bytes.size(),reinterpret_cast<jbyte*>(bytes.data()));
const auto result=source->submitEncodedField(jni::Make<std::string>(env,fieldKey), {jni::Make<std::string>(env,fieldId),static_cast<uint64_t>(generation),static_cast<uint64_t>(dataVersion)}, *contract, bytes);
return static_cast<jni::jint>(result);
}
jni::jint NativeMapView::clearRasterArraySourceEncodedField(JNIEnv& env,const jni::String& sourceId,const jni::String& fieldKey) { auto* s=dynamic_cast<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(env,sourceId))); if(!s)return 0; return s->clearEncodedField(jni::Make<std::string>(env,fieldKey)) ? 1 : -1; }
jni::jint NativeMapView::clearAllRasterArraySourceEncodedFields(JNIEnv& env,const jni::String& sourceId) { auto* s=dynamic_cast<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(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<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(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<std::string>(env,fieldKey),{static_cast<uint16_t>(semanticID),static_cast<uint16_t>(groupID)},static_cast<mbgl::particle::FieldValueClass>(valueClass),static_cast<uint16_t>(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<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(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<std::string>(env,fieldKey); binding.expectedUnitID=static_cast<uint16_t>(unitID); if(mode==0&&valid(uSemanticID,uGroupID)&&valid(vSemanticID,vGroupID)&&inactive(directionSemanticID,directionGroupID)){binding.mode=mbgl::particle::ParticleBindingMode::VectorComponents;binding.uPlane={static_cast<uint16_t>(uSemanticID),static_cast<uint16_t>(uGroupID)};binding.vPlane={static_cast<uint16_t>(vSemanticID),static_cast<uint16_t>(vGroupID)};} else if(mode==1&&inactive(uSemanticID,uGroupID)&&inactive(vSemanticID,vGroupID)&&valid(directionSemanticID,directionGroupID)){binding.mode=mbgl::particle::ParticleBindingMode::DirectionAngle;binding.directionPlane={static_cast<uint16_t>(directionSemanticID),static_cast<uint16_t>(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<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(env,sourceId))); if(!s)return 0; return timestamp>=0&&s->setRequestedForecastTime(static_cast<uint64_t>(timestamp))?1:-1; }
jni::jint NativeMapView::setRasterArraySourceEncodedForecastPlaybackState(JNIEnv& env,const jni::String& sourceId,jni::jboolean playing) { auto* s=dynamic_cast<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(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<mbgl::style::RasterArraySource*>(map->getStyle().getSource(jni::Make<std::string>(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"),

View File

@@ -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<jbyte>&);
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&);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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));

View File

@@ -0,0 +1,97 @@
#include <mbgl/particle/field/field_profile_contract_json.hpp>
#include <rapidjson/document.h>
#include <cstdint>
#include <limits>
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<FieldValueClass> 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<FieldComponent> 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<FieldTimeRelation> 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<ResolvedFieldProfileContract> 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<uint32_t>::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<uint16_t>(schema);
result.profileID = std::move(profileID);
result.profileVersion = static_cast<uint32_t>(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<uint16_t>(semanticID), *parsedClass, static_cast<uint16_t>(unitID),
static_cast<uint16_t>(groupID), *parsedComponent, requiredFlag});
}
return result.isStructurallyValid() ? std::optional<ResolvedFieldProfileContract>(std::move(result)) : std::nullopt;
}
} // namespace mbgl::particle

View File

@@ -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

View File

@@ -0,0 +1,36 @@
#include <mbgl/particle/field/field_profile_contract_json.hpp>
#include <gtest/gtest.h>
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

View File

@@ -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。