refactor(proto): use Wire native emitAppliedOptions, delete custom SchemaHandler

The upstream proto (meshtastic/protobufs#905) has been simplified to use
a scalar bool extension instead of a message wrapper:
  [(meshtastic.diy_only) = true]
Wire 6's emitAppliedOptions now natively generates @DiyOnlyOption(true)
annotations on fields, eliminating the need for our custom
FieldMetadataSchemaHandler.
Changes:
- Delete FieldMetadataSchemaHandler.kt (~200 LOC)
- Remove wire-schema dependency from build-logic
- Remove custom {} handler block from core:proto build
- Add emitAppliedOptions = true to Wire kotlin {} config
- Update FieldMetadataDemo to demonstrate the native annotation
- Bump protobufs submodule to metadata-experiment branch
This commit is contained in:
James Rich
2026-04-30 15:49:57 -05:00
parent f610789386
commit d11af85aee
5 changed files with 77 additions and 209 deletions

View File

@@ -56,7 +56,6 @@ dependencies {
compileOnly(libs.androidx.room.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
compileOnly(libs.test.retry.gradlePlugin)
implementation(libs.wire.schema)
detektPlugins(libs.detekt.formatting)
}

View File

@@ -1,197 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<String, MutableList<FieldEntry>>()
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<Type>,
registry: MutableMap<String, MutableList<FieldEntry>>,
) {
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<ProtoMember, Any?>
return when (value) {
is Map<*, *> -> {
val map = value as Map<ProtoMember, Any?>
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, List<FieldEntry>>): 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<ProtoFieldMeta> = 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<Path> = 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<String>,
excludes: List<String>,
exclusive: Boolean,
outDirectory: String,
options: Map<String, String>,
): SchemaHandler = FieldMetadataSchemaHandler()
}

View File

@@ -30,7 +30,6 @@ kotlin {
sourceSets {
commonMain {
dependencies { api(libs.wire.runtime) }
kotlin.srcDir(layout.buildDirectory.dir("generated/source/wire-metadata"))
}
}
}
@@ -50,15 +49,10 @@ wire {
// Codebase is already written to use the nullable properties (e.g. packet.decoded vs
// 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
// Emit Kotlin annotations from scalar proto field options (e.g. diy_only).
// Wire natively generates @DiyOnly annotations on fields that carry the option.
emitAppliedOptions = true
}
root("meshtastic.*")
prune("meshtastic.MeshPacket#delayed")

View File

@@ -0,0 +1,72 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.proto
import org.meshtastic.proto.DiyOnlyOption
/**
* DEMO: Wire 6 natively generates `@DiyOnlyOption(true)` on proto fields that carry
* `[(meshtastic.diy_only) = true]` — zero custom build code needed.
*
* The annotation has `RUNTIME` retention and targets `PROPERTY` + `FIELD`, so
* it can be queried via reflection on JVM/Android or used as a compile-time marker.
*
* Generated code in `Config.PositionConfig`:
* ```kotlin
* @DiyOnlyOption(true)
* @field:WireField(tag = 8, ...)
* public val rx_gpio: Int = 0,
* ```
*
* See: https://github.com/meshtastic/protobufs/pull/905
*/
object FieldMetadataDemo {
/**
* The [DiyOnlyOption] annotation is available at compile time for reference.
* On JVM/Android, you can reflect over it at runtime:
*
* ```kotlin
* // JVM/Android only (kotlin-reflect):
* val isDiy = Config.PositionConfig::rx_gpio
* .findAnnotation<DiyOnlyOption>()?.value ?: false
* ```
*
* For KMP-safe access without reflection, use a constants map:
*/
val diyOnlyFields: Set<String> = setOf("rx_gpio", "tx_gpio")
/**
* UI gating — hide settings that are diy_only when device isn't DIY:
*
* ```kotlin
* if ("rx_gpio" !in FieldMetadataDemo.diyOnlyFields || deviceIsDiy) {
* DropDownPreference(title = stringResource(Res.string.gps_receive_gpio), ...)
* }
* ```
*/
fun uiGatingExample(deviceIsDiy: Boolean) {
val allFields = listOf("fixed_position", "gps_enabled", "rx_gpio", "tx_gpio")
for (field in allFields) {
val isDiyOnly = field in diyOnlyFields
if (!isDiyOnly || deviceIsDiy) {
println("Showing $field setting")
}
}
}
}