From f6107893863e629cab1d2deb94d170cc3258548d Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 30 Apr 2026 14:33:56 -0500 Subject: [PATCH] =?UTF-8?q?feat(proto):=20add=20Wire=20custom=20SchemaHand?= =?UTF-8?q?ler=20for=20proto=20field=20metadata=20Adds=20build-time=20code?= =?UTF-8?q?gen=20that=20auto-generates=20a=20ConfigFieldMetadataRegistry?= =?UTF-8?q?=20from=20(meshtastic.config=5Ffield)=20annotations=20in=20.pro?= =?UTF-8?q?to=20files.=20-=20New=20FieldMetadataSchemaHandler=20in=20build?= =?UTF-8?q?-logic=20walks=20all=20message=20fields=20-=20Emits=20a=20stati?= =?UTF-8?q?c=20Kotlin=20registry=20mapping=20(messageType,=20fieldTag)=20?= =?UTF-8?q?=E2=86=92=20metadata=20-=20Zero=20runtime=20cost,=20no=20wire-s?= =?UTF-8?q?chema=20dep=20in=20app=20binary=20-=20Adding=20annotations=20to?= =?UTF-8?q?=20new=20fields=20requires=20no=20code=20changes=20Companion=20?= =?UTF-8?q?to:=20https://github.com/meshtastic/protobufs/pull/905?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-logic/convention/build.gradle.kts | 1 + .../proto/FieldMetadataSchemaHandler.kt | 197 ++++++++++++++++++ core/proto/build.gradle.kts | 16 +- core/proto/src/main/proto | 2 +- gradle/libs.versions.toml | 1 + 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/proto/FieldMetadataSchemaHandler.kt diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 71823c763..700a84bb5 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { compileOnly(libs.androidx.room.gradlePlugin) compileOnly(libs.spotless.gradlePlugin) compileOnly(libs.test.retry.gradlePlugin) + implementation(libs.wire.schema) detektPlugins(libs.detekt.formatting) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/proto/FieldMetadataSchemaHandler.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/proto/FieldMetadataSchemaHandler.kt new file mode 100644 index 000000000..ebf20c037 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/proto/FieldMetadataSchemaHandler.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.buildlogic.proto + +import com.squareup.wire.schema.Extend +import com.squareup.wire.schema.Field +import com.squareup.wire.schema.MessageType +import com.squareup.wire.schema.Options.Companion.FIELD_OPTIONS +import com.squareup.wire.schema.ProtoFile +import com.squareup.wire.schema.ProtoMember +import com.squareup.wire.schema.Schema +import com.squareup.wire.schema.SchemaHandler +import com.squareup.wire.schema.Service +import com.squareup.wire.schema.Type +import okio.Path +import okio.Path.Companion.toPath +import okio.buffer + +/** + * Wire custom [SchemaHandler] that walks every message field annotated with + * `(meshtastic.config_field)` and emits a Kotlin source file containing a + * static metadata registry. + * + * This runs at build time during Wire codegen — the generated registry is + * compiled into the app with zero runtime overhead and no `wire-schema` dep. + * + * ## Auto-discovery + * + * The handler automatically finds ALL fields across ALL messages that carry + * `(meshtastic.config_field)`. Adding the annotation to new proto fields + * requires **zero code changes here** — the registry regenerates on next build. + * + * ## When to modify this file + * + * Only if the `ConfigFieldMetadata` message in `field_metadata.proto` gains + * new fields (e.g. `admin_only`, `min_value`, `max_value`). In that case, + * update [parseConfigFieldOption] and [FieldMeta] to extract the new values. + */ +class FieldMetadataSchemaHandler : SchemaHandler() { + + private val configFieldMember: ProtoMember = + ProtoMember.get(FIELD_OPTIONS, "meshtastic.config_field") + + /** + * Override the top-level handle to walk all types and collect field metadata, + * then emit a single generated file. + */ + override fun handle(schema: Schema, context: Context) { + val registry = mutableMapOf>() + + for (protoFile in schema.protoFiles) { + if (!context.inSourcePath(protoFile)) continue + collectFieldMetadata(protoFile.types, registry) + } + + if (registry.isNotEmpty()) { + val output = generateRegistrySource(registry) + val outputPath = context.outDirectory / "org/meshtastic/proto/ConfigFieldMetadataRegistry.kt" + context.fileSystem.createDirectories(outputPath.parent!!) + context.fileSystem.sink(outputPath).buffer().use { sink -> + sink.writeUtf8(output) + } + } + } + + private fun collectFieldMetadata( + types: List, + registry: MutableMap>, + ) { + for (type in types) { + if (type is MessageType) { + for (field in type.fieldsAndOneOfFields) { + val optionValue = field.options.get(configFieldMember) + if (optionValue != null) { + val messageKey = type.type.toString() + val meta = parseConfigFieldOption(optionValue) + registry.getOrPut(messageKey) { mutableListOf() } + .add(FieldEntry(name = field.name, tag = field.tag, meta = meta)) + } + } + // Recurse into nested types + collectFieldMetadata(type.nestedTypes, registry) + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun parseConfigFieldOption(value: Any?): FieldMeta { + // Wire schema options are returned as Map + return when (value) { + is Map<*, *> -> { + val map = value as Map + val diyOnly = map.entries.find { it.key.member == "diy_only" }?.value + FieldMeta(diyOnly = diyOnly == "true" || diyOnly == true) + } + else -> FieldMeta(diyOnly = false) + } + } + + /** + * Converts a proto type like "meshtastic.Config.PositionConfig" to the + * Wire-generated Kotlin class name "org.meshtastic.proto.Config.PositionConfig". + */ + private fun protoTypeToKotlinClass(protoType: String): String { + // Wire maps package "meshtastic" → "org.meshtastic.proto" + // and nested messages become nested Kotlin classes. + val parts = protoType.split(".") + return if (parts.firstOrNull() == "meshtastic") { + "org.meshtastic.proto." + parts.drop(1).joinToString(".") + } else { + protoType + } + } + + private fun generateRegistrySource(registry: Map>): String { + val sb = StringBuilder() + sb.appendLine("// AUTO-GENERATED by FieldMetadataSchemaHandler — do not edit.") + sb.appendLine("@file:Suppress(\"ktlint\")") + sb.appendLine() + sb.appendLine("package org.meshtastic.proto") + sb.appendLine() + sb.appendLine("/**") + sb.appendLine(" * Build-time generated field metadata from `(meshtastic.config_field)` proto annotations.") + sb.appendLine(" *") + sb.appendLine(" * Usage — just reference the field by name:") + sb.appendLine(" * ```") + sb.appendLine(" * PositionConfigFields.rx_gpio.diyOnly // true") + sb.appendLine(" * PositionConfigFields.tx_gpio.diyOnly // true") + sb.appendLine(" * ```") + sb.appendLine(" */") + sb.appendLine() + sb.appendLine("/** Metadata for a single proto field. */") + sb.appendLine("data class ProtoFieldMeta(val name: String, val tag: Int, val diyOnly: Boolean)") + sb.appendLine() + + // Generate one object per message type + for ((protoType, fields) in registry.entries.sortedBy { it.key }) { + val kotlinClass = protoTypeToKotlinClass(protoType) + // "meshtastic.Config.PositionConfig" → "PositionConfigFields" + val objectName = protoType.split(".").last() + "Fields" + + sb.appendLine("/** Field metadata for [${kotlinClass}]. */") + sb.appendLine("object $objectName {") + for (entry in fields.sortedBy { it.name }) { + sb.appendLine(" val ${entry.name} = ProtoFieldMeta(name = \"${entry.name}\", tag = ${entry.tag}, diyOnly = ${entry.meta.diyOnly})") + } + sb.appendLine() + sb.appendLine(" /** All fields carrying metadata on this message. */") + sb.appendLine(" val all: kotlin.collections.List = listOf(${fields.sortedBy { it.name }.joinToString { it.name }})") + sb.appendLine("}") + sb.appendLine() + } + + return sb.toString() + } + + // Unused — we override the top-level handle() instead + override fun handle(type: Type, context: Context): Path? = null + override fun handle(service: Service, context: Context): List = emptyList() + override fun handle(extend: Extend, field: Field, context: Context): Path? = null + + private data class FieldMeta(val diyOnly: Boolean) + private data class FieldEntry(val name: String, val tag: Int, val meta: FieldMeta) +} + +/** Factory for Wire's Gradle plugin to instantiate the handler. */ +class FieldMetadataSchemaHandlerFactory : SchemaHandler.Factory { + override fun create( + includes: List, + excludes: List, + exclusive: Boolean, + outDirectory: String, + options: Map, + ): SchemaHandler = FieldMetadataSchemaHandler() +} + + + + + + + diff --git a/core/proto/build.gradle.kts b/core/proto/build.gradle.kts index e60195e19..4d2ab4b50 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -27,7 +27,12 @@ kotlin { // Override minSdk for ATAK compatibility (standard is 26) android { minSdk = 21 } - sourceSets { commonMain.dependencies { api(libs.wire.runtime) } } + sourceSets { + commonMain { + dependencies { api(libs.wire.runtime) } + kotlin.srcDir(layout.buildDirectory.dir("generated/source/wire-metadata")) + } + } } wire { @@ -46,6 +51,15 @@ wire { // packet.payload_variant.decoded). boxOneOfsMinSize = 5000 } + // Emit a static ConfigFieldMetadataRegistry from (meshtastic.config_field) annotations. + // Fully automatic — annotating new proto fields requires no changes here or in the handler. + // Only modify the handler if ConfigFieldMetadata gains new sub-fields. + custom { + schemaHandlerFactoryClass = + "org.meshtastic.buildlogic.proto.FieldMetadataSchemaHandlerFactory" + out = layout.buildDirectory.dir("generated/source/wire-metadata").get().asFile.path + exclusive = false + } root("meshtastic.*") prune("meshtastic.MeshPacket#delayed") prune("meshtastic.MeshPacket.Delayed") diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index 10a16897b..9bf4a87d8 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit 10a16897b46914854bb46bd94bedb16c6fad3a8b +Subproject commit 9bf4a87d88a971c89aba1ad2bd9c33fac4f411f5 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c603a00e1..53629727b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -167,6 +167,7 @@ maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets" mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } +wire-schema = { module = "com.squareup.wire:wire-schema", version.ref = "wire" } zxing-core = { module = "com.google.zxing:core", version = "3.5.4" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" }