mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-03 00:54:34 -04:00
feat(settings): label enum pickers from the schema (#7298)
This commit is contained in:
1 parent
8c22e3b2ae
commit
720c7a0850
20 files changed
+353
-226
No files matched your search
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 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.schemastrings
|
||||
|
||||
/**
|
||||
* The Kotlin half of the sync: a lookup from an enum value to the resource holding its schema label, so a picker shows
|
||||
* the label every client shares instead of the constant's Kotlin name.
|
||||
*
|
||||
* The keys these build are [SchemaCatalog.keyFor]'s, so this file and `schema_strings.xml` cannot name a value
|
||||
* differently. What the lookup cannot do is fail at compile time: a resource that is not there reads as null, which is
|
||||
* why `SchemaEnumLabelsTest` walks every labelled constant.
|
||||
*/
|
||||
object EnumLabelsKt {
|
||||
|
||||
/** One enum the schema labels: its proto path, the prefix its values share, and whether any value is described. */
|
||||
data class LabelledEnum(val path: String, val prefix: String, val hasDescriptions: Boolean)
|
||||
|
||||
/**
|
||||
* The whole generated file. Spotless reformats it afterwards, so this is the input to the formatter rather than the
|
||||
* committed bytes - which is why `RepositorySyncTest` compares the branches it declares, not its text. The notice
|
||||
* sits below the imports because the licence-header step deletes anything between the header and `package`.
|
||||
*/
|
||||
fun render(header: String, pin: String, enums: List<LabelledEnum>): String = buildString {
|
||||
append(header)
|
||||
append("package $PACKAGE\n\n")
|
||||
imports(enums).forEach { append("import ").append(it).append('\n') }
|
||||
append('\n').append(notice(pin)).append('\n')
|
||||
append(LABEL_DOC)
|
||||
append("@Suppress(\"CyclomaticComplexMethod\")\n")
|
||||
append("fun Enum<*>.schemaLabelRes(): StringResource? = when (this) {\n")
|
||||
enums.forEach { append(branch(it, "schemaLabel")) }
|
||||
append(" else -> null\n}\n")
|
||||
append(DESCRIPTION_DOC)
|
||||
append("@Suppress(\"CyclomaticComplexMethod\")\n")
|
||||
append("fun Enum<*>.schemaDescriptionRes(): StringResource? = when (this) {\n")
|
||||
enums.filter { it.hasDescriptions }.forEach { append(branch(it, "schemaDescription")) }
|
||||
append(" else -> null\n}\n")
|
||||
append(HELPER)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `when` branch. A branch that would not fit on a line is an error rather than a wrap: a wrapped branch makes
|
||||
* the formatter space out every other branch in the same `when`, and the fix belongs in the name, not here.
|
||||
*/
|
||||
private fun branch(enum: LabelledEnum, function: String): String {
|
||||
val line = " is ${enum.path} -> $function(\"${enum.prefix}\")"
|
||||
require(line.length <= MAX_LINE) { "branch for ${enum.path} is ${line.length} characters, over $MAX_LINE" }
|
||||
return "$line\n"
|
||||
}
|
||||
|
||||
/** `is Config.DeviceConfig.Role -> ...`, as the committed file spells it, for the drift check. */
|
||||
val branchPattern: Regex =
|
||||
Regex("""^\s*is ([\w.]+) -> schema(Label|Description)\("([^"]+)"\)""", RegexOption.MULTILINE)
|
||||
|
||||
/** `Config.DeviceConfig.Role` needs only `Config` imported; a top-level enum needs itself. */
|
||||
private fun imports(enums: List<LabelledEnum>): List<String> =
|
||||
(FIXED_IMPORTS + enums.map { "$PROTO_PACKAGE.${it.path.substringBefore('.')}" }).distinct().sorted()
|
||||
|
||||
fun notice(pin: String): String =
|
||||
"// Generated by ./gradlew :schema-strings:sync from org.meshtastic:protobufs $pin.\n" +
|
||||
"// Do not edit: change the schema, then run sync."
|
||||
|
||||
private const val MAX_LINE = 120
|
||||
private const val PACKAGE = "org.meshtastic.core.model"
|
||||
private const val PROTO_PACKAGE = "org.meshtastic.proto"
|
||||
|
||||
private val FIXED_IMPORTS =
|
||||
listOf(
|
||||
"org.jetbrains.compose.resources.StringResource",
|
||||
"org.meshtastic.core.resources.Res",
|
||||
"org.meshtastic.core.resources.allStringResources",
|
||||
)
|
||||
|
||||
private val LABEL_DOC =
|
||||
"""
|
||||
|
|
||||
|/**
|
||||
| * The schema's label for this enum value, or null where the schema does not name it. A picker shows this in
|
||||
| * place of the constant's Kotlin name, and falls back to that name when it is null, so a value the schema has
|
||||
| * not reached still renders.
|
||||
| */
|
||||
|"""
|
||||
.trimMargin()
|
||||
|
||||
private val DESCRIPTION_DOC =
|
||||
"""
|
||||
|
|
||||
|/** The schema's one-sentence explanation of this enum value, or null where it has none. */
|
||||
|"""
|
||||
.trimMargin()
|
||||
|
||||
private val HELPER =
|
||||
"""
|
||||
|
|
||||
|private fun Enum<*>.schemaLabel(prefix: String): StringResource? =
|
||||
| Res.allStringResources[prefix + name.lowercase()]
|
||||
|
|
||||
|private fun Enum<*>.schemaDescription(prefix: String): StringResource? =
|
||||
| Res.allStringResources[prefix + name.lowercase() + "_description"]
|
||||
|"""
|
||||
.trimMargin()
|
||||
}
|
||||
@@ -73,6 +73,22 @@ object SchemaCatalog {
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every enum the schema labels, in proto-path order, for the Kotlin accessors that read those labels. */
|
||||
fun labelledEnums(): List<EnumLabelsKt.LabelledEnum> = generatedTypePaths().mapNotNull { path ->
|
||||
val type = Class.forName(GENERATED_PACKAGE + path.replace('.', '$'))
|
||||
val constants = type.enumConstants ?: return@mapNotNull null
|
||||
val metadata =
|
||||
constants.filterIsInstance<WireEnum>().mapNotNull {
|
||||
FieldMetadataRegistry.forEnumValue(PROTO_PACKAGE + path, it.value)
|
||||
}
|
||||
if (metadata.none { !it.label.isNullOrBlank() }) return@mapNotNull null
|
||||
EnumLabelsKt.LabelledEnum(
|
||||
path = path,
|
||||
prefix = keyFor(path, ""),
|
||||
hasDescriptions = metadata.any { !it.description.isNullOrBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The resource for a field or enum value. Each message segment is lowercased with a trailing `Config` dropped, and
|
||||
* the `Config`/`ModuleConfig` container is dropped: `ModuleConfig.MQTTConfig.address` is `schema_mqtt_address`.
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
package org.meshtastic.schemastrings
|
||||
|
||||
import java.io.File
|
||||
import java.time.Year
|
||||
|
||||
/** The repository view of the sync: where the files live, what the English file should contain, how to write it. */
|
||||
class SchemaStringsSync(rootDir: File) {
|
||||
class SchemaStringsSync(private val rootDir: File) {
|
||||
private val valuesRoot = rootDir.resolve("core/resources/src/commonMain/composeResources")
|
||||
private val catalog = rootDir.resolve("gradle/libs.versions.toml")
|
||||
val englishStrings: File = valuesRoot.resolve("values/$HAND_WRITTEN")
|
||||
val englishSchemaStrings: File = valuesRoot.resolve("values/$GENERATED")
|
||||
val enumLabels: File = rootDir.resolve("core/model/src/commonMain/kotlin/$GENERATED_KT")
|
||||
|
||||
/** Every hand-written strings file: the English one and one per locale. */
|
||||
val handWrittenFiles: List<File>
|
||||
@@ -51,6 +53,25 @@ class SchemaStringsSync(rootDir: File) {
|
||||
notice = notice(catalogPin),
|
||||
)
|
||||
|
||||
/**
|
||||
* The Kotlin accessors the registry implies. The licence header is the committed file's own where there is one, so
|
||||
* a rerun does not restamp a year Spotless is happy with - everything before `package` is that header.
|
||||
*/
|
||||
fun expectedEnumLabels(): String = EnumLabelsKt.render(
|
||||
header = enumLabels.takeIf { it.isFile }?.let { licenceHeader(it.readText()) } ?: freshLicenceHeader(),
|
||||
pin = catalogPin,
|
||||
enums = SchemaCatalog.labelledEnums(),
|
||||
)
|
||||
|
||||
/** The pin the committed Kotlin file says it was built from, or null when it is absent or unmarked. */
|
||||
val recordedEnumLabelsPin: String?
|
||||
get() = enumLabels.takeIf { it.isFile }?.let { noticePin.find(it.readText())?.groupValues?.get(1) }
|
||||
|
||||
private fun licenceHeader(kotlin: String): String = kotlin.substringBefore("package ").substringBefore("// ")
|
||||
|
||||
private fun freshLicenceHeader(): String =
|
||||
rootDir.resolve("config/spotless/copyright.kt").readText().replace("\$YEAR", YEAR) + "\n"
|
||||
|
||||
/** Schema-named keys that someone wrote into a hand-written file, by file. Those files must never carry one. */
|
||||
fun strays(): Map<File, Set<String>> = handWrittenFiles
|
||||
.associateWith { file -> schemaKeys(StringsXml.bodies(file.readText()).keys) }
|
||||
@@ -59,7 +80,9 @@ class SchemaStringsSync(rootDir: File) {
|
||||
fun apply(): String {
|
||||
val text = expectedEnglish()
|
||||
englishSchemaStrings.writeText(text)
|
||||
return "wrote ${englishSchemaStrings.path} (${StringsXml.bodies(text).size} strings from protobufs $catalogPin)"
|
||||
enumLabels.writeText(expectedEnumLabels())
|
||||
return "wrote ${StringsXml.bodies(text).size} strings and " +
|
||||
"${SchemaCatalog.labelledEnums().size} enum accessors from protobufs $catalogPin"
|
||||
}
|
||||
|
||||
private fun schemaKeys(names: Set<String>): Set<String> = names.filterTo(HashSet()) { it.startsWith("schema_") }
|
||||
@@ -67,6 +90,8 @@ class SchemaStringsSync(rootDir: File) {
|
||||
companion object {
|
||||
const val HAND_WRITTEN = "strings.xml"
|
||||
const val GENERATED = "schema_strings.xml"
|
||||
const val GENERATED_KT = "org/meshtastic/core/model/SchemaEnumLabels.kt"
|
||||
private val YEAR = Year.now().toString()
|
||||
|
||||
private val catalogPinLine = Regex("""^meshtastic-protobufs = "([^"]+)"$""", RegexOption.MULTILINE)
|
||||
private val noticePin = Regex("""from org\.meshtastic:protobufs (\S+)\.""")
|
||||
|
||||
@@ -51,6 +51,54 @@ class RepositorySyncTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the generated Kotlin records the pin it was built from`() {
|
||||
assertNotNull(
|
||||
sync.recordedEnumLabelsPin,
|
||||
"core/model SchemaEnumLabels.kt carries no pin: run ./gradlew :schema-strings:sync",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the accessors the file declares rather than its bytes: Spotless reformats what the sync writes, so its
|
||||
* text is the formatter's and only the set of enums and prefixes is the sync's to promise.
|
||||
*/
|
||||
@Test
|
||||
fun `at a matching pin the generated Kotlin declares the accessors the registry implies`() {
|
||||
if (sync.recordedEnumLabelsPin != sync.catalogPin) {
|
||||
println("protobufs moved to ${sync.catalogPin}; scheduled-updates re-syncs SchemaEnumLabels.kt")
|
||||
return
|
||||
}
|
||||
val committed =
|
||||
EnumLabelsKt.branchPattern
|
||||
.findAll(sync.enumLabels.readText())
|
||||
.map { Triple(it.groupValues[1], it.groupValues[2], it.groupValues[3]) }
|
||||
.toSet()
|
||||
val expected =
|
||||
SchemaCatalog.labelledEnums()
|
||||
.flatMap { enum ->
|
||||
listOfNotNull(
|
||||
Triple(enum.path, "Label", enum.prefix),
|
||||
Triple(enum.path, "Description", enum.prefix).takeIf { enum.hasDescriptions },
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
assertEquals(expected, committed, "core/model SchemaEnumLabels.kt is stale: run ./gradlew :schema-strings:sync")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every labelled enum builds a prefix the generated strings carry`() {
|
||||
val strings = StringsXml.bodies(sync.englishSchemaStrings.readText()).keys
|
||||
val missing = SchemaCatalog.labelledEnums().filter { enum -> strings.none { it.startsWith(enum.prefix) } }
|
||||
|
||||
assertTrue(
|
||||
missing.isEmpty(),
|
||||
"these enums build a resource prefix nothing in schema_strings.xml matches: " +
|
||||
missing.joinToString { "${it.path} (${it.prefix})" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no schema key is written by hand`() {
|
||||
val strays = sync.strays()
|
||||
|
||||
Reference in new issue
Block a user