From bfcdb2f201aedf618316dbf873aaf6c31787bb47 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 26 Aug 2026 11:40:47 +0000 Subject: [PATCH] feat(settings): send the optional ham long_name alongside the call sign (#6875) Co-authored-by: Claude Opus 5 --- .skills/compose-ui/strings-index.txt | 1 + .../usecase/settings/RadioConfigUseCase.kt | 4 +- .../org/meshtastic/core/model/HamName.kt | 103 +++++++++++++ .../org/meshtastic/core/model/HamNameTest.kt | 136 ++++++++++++++++++ .../core/repository/AdminController.kt | 11 +- .../composeResources/values/strings.xml | 3 +- .../core/service/AdminControllerImpl.kt | 3 +- .../core/service/RadioControllerImplTest.kt | 30 +++- .../settings/radio/RadioConfigViewModel.kt | 8 +- .../radio/component/UserConfigItemList.kt | 128 +++++++++++++---- .../radio/RadioConfigViewModelTest.kt | 22 +++ .../radio/component/UserNameFieldsTest.kt | 121 ++++++++++++++++ 12 files changed, 528 insertions(+), 42 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/HamNameTest.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/UserNameFieldsTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index ecab43248d..781c26a819 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -832,6 +832,7 @@ gps_receive_gpio gps_transmit_gpio grant_permission green +ham_long_name_summary hardware hardware_model heading diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt index 1e46dbb830..ba2cdb9306 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt @@ -52,8 +52,8 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont } /** - * Enables amateur-radio (ham) mode on the locally connected node via `set_ham_mode`. At protobufs 2.7.25 only - * `call_sign` and `short_name` are user-supplied; `long_name` becomes settable when meshtastic/protobufs#941 ships. + * Enables amateur-radio (ham) mode on the locally connected node via `set_ham_mode`. `call_sign`, `short_name` and + * the optional `long_name` are user-supplied; `tx_power`/`frequency` are filled in by the controller. * * @param destNum The node number to update (must be the local node). * @param hamParameters The ham onboarding parameters. diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt new file mode 100644 index 0000000000..cc0b7d34f0 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt @@ -0,0 +1,103 @@ +/* + * 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 . + */ +package org.meshtastic.core.model + +/** + * The owner name of a licensed (ham) node, which firmware builds from two `HamParameters` fields. + * + * `AdminModule::handleSetHamMode()` joins the call sign and the optional `long_name` with [SEPARATOR] — the form hams + * already use on the air — so `KD2ABC` plus `Attic Heltec` becomes `KD2ABC//Attic Heltec`, and a ham who supplied no + * long name is named after the bare call sign. The device only ever reports that composed name back as + * `User.long_name`, so editing either half means splitting it again: [split] and [compose] are inverses over every name + * firmware can produce. + */ +object HamName { + + /** What firmware puts between the call sign and the long name. */ + const val SEPARATOR = "//" + + /** Usable bytes in `HamParameters.call_sign` (`max_size:8`, one byte of which is the NUL terminator). */ + const val MAX_CALL_SIGN_BYTES = 7 + + /** Usable bytes in `HamParameters.long_name` (`max_size:15`, one byte of which is the NUL terminator). */ + const val MAX_LONG_NAME_BYTES = 14 + + /** + * Joins the two halves the way firmware does. A blank [longName] yields the bare [callSign] — firmware treats unset + * and whitespace-only alike, so composing a separator for either would not match what the node stores. + */ + fun compose(callSign: String, longName: String): String = + if (longName.isBlank()) callSign else callSign + SEPARATOR + longName + + /** + * Splits an owner long name into its call sign and long name at the first [SEPARATOR]. + * + * A name with no separator is all call sign, which is what firmware writes for a ham who supplied no long name. + */ + fun split(ownerLongName: String): Pair { + val at = ownerLongName.indexOf(SEPARATOR) + return if (at < 0) { + ownerLongName to "" + } else { + ownerLongName.substring(0, at) to ownerLongName.substring(at + SEPARATOR.length) + } + } + + /** + * Reshapes an existing owner long name for ham onboarding, so enabling licensed mode does not discard a name the + * operator already chose. + * + * A name that can still serve as a call sign is returned untouched — including one this app composed during an + * earlier licensing, whose halves survive as they are. Anything wider is demoted to the descriptive half, clipped + * to [MAX_LONG_NAME_BYTES], leaving the call sign empty for the operator to fill in. + */ + fun forOnboarding(ownerLongName: String): String { + val (callSign, longName) = split(ownerLongName) + if (callSign.utf8Size() <= MAX_CALL_SIGN_BYTES) return ownerLongName + return compose("", longName.ifBlank { callSign }.limitToBytes(MAX_LONG_NAME_BYTES)) + } + + /** + * Reshapes an owner long name on the way back out of licensed mode. + * + * A fully composed name survives untouched — `KD2ABC//Attic Heltec` is exactly what the node is called, and an + * operator clearing the toggle has no reason to lose it. Only the half-filled name left by an abandoned onboarding, + * where no call sign was ever entered, is flattened, so a stray separator cannot become the node's name. + */ + fun forUnlicensing(ownerLongName: String): String { + val (callSign, longName) = split(ownerLongName) + return if (callSign.isBlank()) longName else ownerLongName + } +} + +/** UTF-8 length in bytes, the unit every `max_size` in the protobuf options is counted in. */ +fun String.utf8Size(): Int = encodeToByteArray().size + +/** Clips to at most [maxBytes] of UTF-8, stepping whole code points so a surrogate pair is never cut in half. */ +private fun String.limitToBytes(maxBytes: Int): String { + if (utf8Size() <= maxBytes) return this + var end = 0 + var used = 0 + while (end < length) { + val step = if (this[end].isHighSurrogate() && end + 1 < length && this[end + 1].isLowSurrogate()) 2 else 1 + val bytes = substring(end, end + step).utf8Size() + if (used + bytes > maxBytes) break + used += bytes + end += step + } + return substring(0, end) +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/HamNameTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/HamNameTest.kt new file mode 100644 index 0000000000..e54ed96224 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/HamNameTest.kt @@ -0,0 +1,136 @@ +/* + * 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 . + */ +package org.meshtastic.core.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Locks down the `CALLSIGN//Long name` composition the app has to agree with firmware's `handleSetHamMode()` on: the + * app builds it optimistically and re-splits whatever the device reports back, so a disagreement would show the + * operator a name their node does not have. + */ +class HamNameTest { + + @Test + fun compose_joins_the_two_halves_with_the_separator() { + assertEquals("KD2ABC//Attic Heltec", HamName.compose("KD2ABC", "Attic Heltec")) + } + + @Test + fun compose_omits_the_separator_when_the_long_name_is_unset() { + // Firmware reads unset and whitespace-only alike, so neither may leave a dangling separator behind. + assertEquals("KD2ABC", HamName.compose("KD2ABC", "")) + assertEquals("KD2ABC", HamName.compose("KD2ABC", " ")) + } + + @Test + fun split_separates_a_composed_name() { + assertEquals("KD2ABC" to "Attic Heltec", HamName.split("KD2ABC//Attic Heltec")) + } + + @Test + fun split_reads_a_name_without_a_separator_as_all_call_sign() { + assertEquals("KD2ABC" to "", HamName.split("KD2ABC")) + assertEquals("" to "", HamName.split("")) + } + + @Test + fun split_keeps_a_later_separator_inside_the_long_name() { + // Only the first separator divides the halves; firmware composes on the first one too. + assertEquals("KD2ABC" to "a//b", HamName.split("KD2ABC//a//b")) + } + + @Test + fun split_round_trips_every_name_compose_can_build() { + listOf( + "KD2ABC" to "Attic Heltec", + "KD2ABC" to "", + "" to "Attic Heltec", + "KD2ABC" to "a//b", + ).forEach { (callSign, longName) -> + assertEquals(callSign to longName, HamName.split(HamName.compose(callSign, longName))) + } + } + + @Test + fun the_widest_pair_the_proto_can_carry_fits_the_firmware_owner_name() { + val widest = HamName.compose("KD2ABCD", "Attic Heltec 3") + assertEquals(HamName.MAX_CALL_SIGN_BYTES, "KD2ABCD".utf8Size()) + assertEquals(HamName.MAX_LONG_NAME_BYTES, "Attic Heltec 3".utf8Size()) + // MAX_LONG_NAME_BYTES in firmware's NodeDB is 24; the composed pair has to arrive whole. + assertTrue(widest.utf8Size() <= 24, "composed name is ${widest.utf8Size()} bytes") + } + + @Test + fun forOnboarding_leaves_a_name_that_can_be_a_call_sign_alone() { + assertEquals("KD2ABC", HamName.forOnboarding("KD2ABC")) + assertEquals("KD2ABCD", HamName.forOnboarding("KD2ABCD")) + assertEquals("", HamName.forOnboarding("")) + } + + @Test + fun forOnboarding_leaves_an_already_composed_name_alone() { + assertEquals("KD2ABC//Attic Heltec", HamName.forOnboarding("KD2ABC//Attic Heltec")) + } + + @Test + fun forOnboarding_demotes_an_over_long_name_to_the_long_name_half() { + // "Attic Heltec" cannot be a call sign, but it is a perfectly good descriptive name — keep it rather than + // making the operator retype it. + assertEquals("" to "Attic Heltec", HamName.split(HamName.forOnboarding("Attic Heltec"))) + } + + @Test + fun forOnboarding_clips_a_demoted_name_to_the_proto_cap() { + val (callSign, longName) = HamName.split(HamName.forOnboarding("Attic Heltec Node Number Three")) + + assertEquals("", callSign) + assertEquals("Attic Heltec N", longName) + assertEquals(HamName.MAX_LONG_NAME_BYTES, longName.utf8Size()) + } + + @Test + fun forOnboarding_clips_on_a_code_point_boundary() { + // Each emoji is a 4-byte, two-UTF-16-char code point, so 14 bytes of budget holds three. The fourth must be + // dropped whole rather than cut into an unpaired surrogate. + val grinning = "\uD83D\uDE00" + val (_, longName) = HamName.split(HamName.forOnboarding(grinning.repeat(5))) + + assertEquals(12, longName.utf8Size()) + assertEquals(longName, longName.encodeToByteArray().decodeToString(), "clipping split a code point") + } + + @Test + fun forOnboarding_keeps_the_long_name_when_only_the_call_sign_is_over_long() { + assertEquals("" to "Attic Heltec", HamName.split(HamName.forOnboarding("Old Node Name//Attic Heltec"))) + } + + @Test + fun forUnlicensing_keeps_a_composed_name_the_node_actually_has() { + assertEquals("KD2ABC//Attic Heltec", HamName.forUnlicensing("KD2ABC//Attic Heltec")) + assertEquals("Attic Heltec", HamName.forUnlicensing("Attic Heltec")) + } + + @Test + fun forUnlicensing_drops_the_separator_left_by_an_abandoned_onboarding() { + // Toggling licensed on and back off without entering a callsign must not name the node "//Attic Heltec". + assertEquals("Attic Heltec", HamName.forUnlicensing(HamName.forOnboarding("Attic Heltec"))) + assertEquals("", HamName.forUnlicensing("")) + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index 9bfab0acf4..552a56ffcd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -60,11 +60,12 @@ interface AdminController { * Enables amateur-radio (ham) mode on a node via `AdminMessage.set_ham_mode`. * * Must target only the locally connected node — firmware ham onboarding is a local operation; the implementation - * ignores requests for any other node. The firmware handler rewrites the owner (long_name = call_sign), flips - * `is_licensed`, disables encryption, applies [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config - * verbatim, and reboots. The implementation echoes the local node's current LoRa values into those two fields so a - * re-send never wipes the node's overrides; caller-supplied [HamParameters.tx_power]/[HamParameters.frequency] are - * ignored. Intentionally absent from [AdminEditScope]: ham enablement is not a batch-edit operation. + * ignores requests for any other node. The firmware handler rewrites the owner (long_name = call_sign, with an + * optional [HamParameters.long_name] appended after `//`), flips `is_licensed`, disables encryption, applies + * [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config verbatim, and reboots. The implementation + * echoes the local node's current LoRa values into those two fields so a re-send never wipes the node's overrides; + * caller-supplied [HamParameters.tx_power]/[HamParameters.frequency] are ignored. Intentionally absent from + * [AdminEditScope]: ham enablement is not a batch-edit operation. */ suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index beaeceecca..e4f2302607 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -190,7 +190,7 @@ Calculating… Call sign - Your amateur radio call sign, up to 8 characters + Your amateur radio call sign, up to 7 characters Camera permission Allow camera access to scan QR codes. Camera could not start. Try again, or close the scanner and reopen it. @@ -862,6 +862,7 @@ GPS Transmit GPIO Grant permission Green + Optional. Appended to your call sign, e.g. KD2ABC//Attic Heltec Hardware Hardware model Heading diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt index 68345c86c4..ec4596d4af 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -30,6 +30,7 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.HamName import org.meshtastic.core.model.Position import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.AdminEditScope @@ -110,7 +111,7 @@ internal class AdminControllerImpl( nodeManager.handleReceivedUser( destNum, currentUser.copy( - long_name = hamParameters.call_sign, + long_name = HamName.compose(hamParameters.call_sign, hamParameters.long_name), short_name = hamParameters.short_name, is_licensed = true, ), diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index 685695eb28..0b812c5730 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -1558,14 +1558,42 @@ class RadioControllerImplTest { sentMessage = (it.args[3] as () -> AdminMessage)() } - controller.setHamMode(123, HamParameters(call_sign = "KK7ABC", short_name = "KK7A"), 42) + controller.setHamMode( + 123, + HamParameters(call_sign = "KK7ABC", short_name = "KK7A", long_name = "Attic Heltec"), + 42, + ) val ham = sentMessage?.set_ham_mode assertEquals("KK7ABC", ham?.call_sign) assertEquals("KK7A", ham?.short_name) + assertEquals("Attic Heltec", ham?.long_name) // Current LoRa values are echoed so a re-send never wipes the node's overrides. assertEquals(20, ham?.tx_power) assertEquals(915.5f, ham?.frequency) + // The optimistic name has to match what firmware composes, or the node reads as renamed until its NodeInfo + // lands and silently corrects it. + verify { + nodeManager.handleReceivedUser( + 123, + existingUser.copy(long_name = "KK7ABC//Attic Heltec", short_name = "KK7A", is_licensed = true), + 0, + false, + ) + } + } + + @Test + fun setHamModeWithoutALongNameNamesTheNodeAfterTheCallSignAlone() = runTest { + val controller = createController(scope = backgroundScope, myNodeNum = 123) + val existingUser = User(id = "!0000007b", long_name = "Old Name", short_name = "OLD") + every { nodeManager.nodeDBbyNodeNum } returns mapOf(123 to Node(num = 123, user = existingUser)) + every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) + everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit + + controller.setHamMode(123, HamParameters(call_sign = "KK7ABC", short_name = "KK7A"), 42) + + // long_name is optional: firmware leaves the bare call sign rather than a dangling "//". verify { nodeManager.handleReceivedUser( 123, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 053f371b35..53229fb43a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -57,6 +57,7 @@ import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.HamName import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo @@ -446,12 +447,13 @@ open class RadioConfigViewModel( private fun setHamMode(destNum: Int, user: User) { safeLaunch(tag = "setHamMode") { _radioConfigState.update { it.copy(userConfig = user) } - // The form's long-name field carries the callsign while licensed (iOS parity). - // When meshtastic/protobufs#941 ships, add long_name here. + // While licensed the form's long name is the composed `CALLSIGN//Long name`; firmware rebuilds that same + // composition from the two HamParameters fields, so send the halves rather than the whole. + val (callSign, longName) = HamName.split(user.long_name) expectRestartIfLocal(RebootBehavior.ALWAYS) radioConfigUseCase.setHamMode( destNum, - HamParameters(call_sign = user.long_name, short_name = user.short_name), + HamParameters(call_sign = callSign, short_name = user.short_name, long_name = longName), onRequestId = ::registerWriteRequestId, ) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index 26553ef905..db541d3d82 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.feature.settings.radio.component +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CardDefaults @@ -23,16 +25,21 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.HamName import org.meshtastic.core.model.isUnmessageableRole +import org.meshtastic.core.model.utf8Size import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.call_sign import org.meshtastic.core.resources.call_sign_summary +import org.meshtastic.core.resources.ham_long_name_summary import org.meshtastic.core.resources.hardware_model import org.meshtastic.core.resources.long_name import org.meshtastic.core.resources.node_id @@ -46,9 +53,14 @@ import org.meshtastic.core.ui.component.RegularPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.proto.User private const val LONG_NAME_MAX_LENGTH = 39 // long_name max_size:40 -private const val CALL_SIGN_MAX_LENGTH = 8 // iOS parity; firmware sets long_name from the callsign +private const val SHORT_NAME_MAX_LENGTH = 4 // short_name max_size:5 + +internal const val USER_LONG_NAME_TEST_TAG = "user_long_name" +internal const val HAM_LONG_NAME_TEST_TAG = "ham_long_name" +internal const val USER_SHORT_NAME_TEST_TAG = "user_short_name" @Composable fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { @@ -60,11 +72,11 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { // Ham onboarding repurposes the long-name field as the callsign, for the local node only (iOS parity). val hamMode = formState.value.is_licensed && state.isLocal - val longNameMax = if (hamMode) CALL_SIGN_MAX_LENGTH else LONG_NAME_MAX_LENGTH - val validLongName = formState.value.long_name.isNotBlank() && formState.value.long_name.length <= longNameMax + val longNameValue = if (hamMode) HamName.split(formState.value.long_name).first else formState.value.long_name + val longNameMax = if (hamMode) HamName.MAX_CALL_SIGN_BYTES else LONG_NAME_MAX_LENGTH + val validLongName = longNameValue.isNotBlank() && longNameValue.utf8Size() <= longNameMax val validShortName = formState.value.short_name.isNotBlank() val validNames = validLongName && validShortName - val focusManager = LocalFocusManager.current RadioConfigScreenList( title = stringResource(Res.string.user), @@ -83,29 +95,12 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onClick = {}, ) HorizontalDivider() - EditTextPreference( - title = stringResource(if (hamMode) Res.string.call_sign else Res.string.long_name), - value = formState.value.long_name, - summary = if (hamMode) stringResource(Res.string.call_sign_summary) else null, - maxSize = longNameMax, + UserNameFields( + formState = formState, + hamMode = hamMode, enabled = state.connected, - isError = !validLongName, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(long_name = it) }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.short_name), - value = formState.value.short_name, - maxSize = 4, // short_name max_size:5 - enabled = state.connected, - isError = !validShortName, - keyboardOptions = - KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(short_name = it) }, + isLongNameError = !validLongName, + isShortNameError = !validShortName, ) HorizontalDivider() RegularPreference( @@ -131,12 +126,18 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { signingSupported = state.metadata?.has_xeddsa, onCheckedChange = { licensed -> val longName = formState.value.long_name - // The field becomes the callsign: clear an over-long name so the user enters one. - val clearForCallsign = licensed && state.isLocal && longName.length > CALL_SIGN_MAX_LENGTH + // The long-name field becomes the callsign while licensed, so reshape the name for the mode + // being entered: one too wide to be a callsign is demoted to the ham long name rather than + // discarded, and abandoning onboarding does not leave a stray separator behind. formState.value = formState.value.copy( is_licensed = licensed, - long_name = if (clearForCallsign) "" else longName, + long_name = + when { + !state.isLocal -> longName + licensed -> HamName.forOnboarding(longName) + else -> HamName.forUnlicensing(longName) + }, ) }, ) @@ -144,3 +145,72 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } } } + +/** + * The owner name fields: long name (relabelled "Call sign" during ham onboarding), the ham-only long name, and the + * short name. + * + * While licensed, a node's owner long name is the `CALLSIGN//Long name` firmware composes from the two + * [org.meshtastic.proto.HamParameters] name fields. The device only ever reports that composed name back, so the two + * halves are split apart for editing and rejoined on every keystroke — [formState] stays the single source of truth, + * which is what keeps Discard, the dirty check and process-death restore working unchanged. + */ +@Composable +internal fun UserNameFields( + formState: ConfigState, + hamMode: Boolean, + enabled: Boolean, + isLongNameError: Boolean, + isShortNameError: Boolean, +) { + val focusManager = LocalFocusManager.current + val (callSign, hamLongName) = remember(formState.value.long_name) { HamName.split(formState.value.long_name) } + val keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done) + val keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + + Column(modifier = Modifier.fillMaxWidth()) { + EditTextPreference( + title = stringResource(if (hamMode) Res.string.call_sign else Res.string.long_name), + value = if (hamMode) callSign else formState.value.long_name, + summary = if (hamMode) stringResource(Res.string.call_sign_summary) else null, + maxSize = if (hamMode) HamName.MAX_CALL_SIGN_BYTES else LONG_NAME_MAX_LENGTH, + enabled = enabled, + isError = isLongNameError, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + modifier = Modifier.testTag(USER_LONG_NAME_TEST_TAG), + onValueChanged = { + val longName = if (hamMode) HamName.compose(it, hamLongName) else it + formState.value = formState.value.copy(long_name = longName) + }, + ) + if (hamMode) { + HorizontalDivider() + // Optional: firmware appends it to the callsign, so an empty field names the node after the callsign. + EditTextPreference( + title = stringResource(Res.string.long_name), + value = hamLongName, + summary = stringResource(Res.string.ham_long_name_summary), + maxSize = HamName.MAX_LONG_NAME_BYTES, + enabled = enabled, + isError = false, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + modifier = Modifier.testTag(HAM_LONG_NAME_TEST_TAG), + onValueChanged = { formState.value = formState.value.copy(long_name = HamName.compose(callSign, it)) }, + ) + } + HorizontalDivider() + EditTextPreference( + title = stringResource(Res.string.short_name), + value = formState.value.short_name, + maxSize = SHORT_NAME_MAX_LENGTH, + enabled = enabled, + isError = isShortNameError, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + modifier = Modifier.testTag(USER_SHORT_NAME_TEST_TAG), + onValueChanged = { formState.value = formState.value.copy(short_name = it) }, + ) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 4af73ef6dd..ca85eb8a49 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -958,6 +958,28 @@ class RadioConfigViewModelTest { verifySuspend(exactly(0)) { radioConfigUseCase.setOwner(any(), any(), any()) } } + @Test + fun `saveUserConfig splits a composed ham name into call sign and long name`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123)) + viewModel = createViewModel() + + // The User form carries the composed name firmware builds; set_ham_mode wants the halves back. + val user = User(long_name = "KK7ABC//Attic Heltec", short_name = "KK7A", is_licensed = true) + everySuspend { radioConfigUseCase.setHamMode(any(), any(), any()) } returns 42 + + viewModel.saveUserConfig(user) + + verifySuspend { + radioConfigUseCase.setHamMode( + 123, + HamParameters(call_sign = "KK7ABC", short_name = "KK7A", long_name = "Attic Heltec"), + any(), + ) + } + } + @Test fun `saveUserConfig sends setOwner for unlicensed user`() = runTest { val node = Node(num = 123, user = User(id = "!123")) diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/UserNameFieldsTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/UserNameFieldsTest.kt new file mode 100644 index 0000000000..52558d1f02 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/UserNameFieldsTest.kt @@ -0,0 +1,121 @@ +/* + * 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 . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A licensed node's owner long name is the `CALLSIGN//Long name` firmware composes, edited here as two fields. These + * lock down that the split and the rejoin stay inverses through the UI — a regression would silently send the operator + * a name they never typed. + */ +@OptIn(ExperimentalTestApi::class) +class UserNameFieldsTest { + + /** Renders [UserNameFields] over [user] and returns an accessor for the edited message. */ + private fun ComposeUiTest.showNameFields(user: User, hamMode: Boolean): () -> User { + lateinit var formState: ConfigState + setContent { + AppTheme { + formState = rememberConfigState(user) + UserNameFields( + formState = formState, + hamMode = hamMode, + enabled = true, + isLongNameError = false, + isShortNameError = false, + ) + } + } + return { formState.value } + } + + /** The text field itself: [EditTextPreference] tags its wrapping column, which carries no text actions. */ + private fun ComposeUiTest.field(tag: String): SemanticsNodeInteraction = + onNode(hasSetTextAction() and hasAnyAncestor(hasTestTag(tag))) + + private fun ComposeUiTest.fieldText(tag: String): String = + field(tag).fetchSemanticsNode().config.getOrNull(SemanticsProperties.EditableText)?.text.orEmpty() + + @Test + fun `the ham long name field only exists while licensed`() = runComposeUiTest { + showNameFields(User(long_name = "Attic Heltec", short_name = "ATTC"), hamMode = false) + + onNodeWithTag(HAM_LONG_NAME_TEST_TAG).assertDoesNotExist() + assertEquals("Attic Heltec", fieldText(USER_LONG_NAME_TEST_TAG)) + } + + @Test + fun `the call sign field shows only the call sign half of a composed name`() = runComposeUiTest { + showNameFields(User(long_name = "KD2ABC//Attic Heltec", short_name = "ABC"), hamMode = true) + + assertEquals("KD2ABC", fieldText(USER_LONG_NAME_TEST_TAG)) + assertEquals("Attic Heltec", fieldText(HAM_LONG_NAME_TEST_TAG)) + } + + @Test + fun `editing the call sign keeps the long name half`() = runComposeUiTest { + val user = showNameFields(User(long_name = "KD2ABC//Attic Heltec", short_name = "ABC"), hamMode = true) + + field(USER_LONG_NAME_TEST_TAG).performTextClearance() + field(USER_LONG_NAME_TEST_TAG).performTextInput("N0CALL") + + assertEquals("N0CALL//Attic Heltec", user().long_name) + } + + @Test + fun `editing the long name keeps the call sign`() = runComposeUiTest { + val user = showNameFields(User(long_name = "KD2ABC", short_name = "ABC"), hamMode = true) + + field(HAM_LONG_NAME_TEST_TAG).performTextInput("Garage") + + assertEquals("KD2ABC//Garage", user().long_name) + } + + @Test + fun `clearing the long name leaves the node named after the call sign alone`() = runComposeUiTest { + val user = showNameFields(User(long_name = "KD2ABC//Attic Heltec", short_name = "ABC"), hamMode = true) + + field(HAM_LONG_NAME_TEST_TAG).performTextClearance() + + assertEquals("KD2ABC", user().long_name) + } + + @Test + fun `the widest pair the proto can carry composes whole`() = runComposeUiTest { + val user = showNameFields(User(long_name = "KD2ABCD", short_name = "ABC"), hamMode = true) + + field(HAM_LONG_NAME_TEST_TAG).performTextInput("Attic Heltec 3") + + assertEquals("KD2ABCD//Attic Heltec 3", user().long_name) + } +}