feat(settings): send the optional ham long_name alongside the call sign (#6875)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AustinandClaude Opus 5 authored and GitHub committed 2026-08-26 11:40:47 +00:00
1 parent dbf77b1e45
commit bfcdb2f201
12 files changed
+528 -42

No files matched your search

+1
View File
@@ -832,6 +832,7 @@ gps_receive_gpio
gps_transmit_gpio
grant_permission
green
ham_long_name_summary
hardware
hardware_model
heading
@@ -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.
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, String> {
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)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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(""))
}
}
@@ -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)
@@ -190,7 +190,7 @@
</plurals>
<string name="calculating">Calculating…</string>
<string name="call_sign">Call sign</string>
<string name="call_sign_summary">Your amateur radio call sign, up to 8 characters</string>
<string name="call_sign_summary">Your amateur radio call sign, up to 7 characters</string>
<string name="camera_permission">Camera permission</string>
<string name="camera_permission_rationale">Allow camera access to scan QR codes.</string>
<string name="camera_unavailable">Camera could not start. Try again, or close the scanner and reopen it.</string>
@@ -862,6 +862,7 @@
<string name="gps_transmit_gpio">GPS Transmit GPIO</string>
<string name="grant_permission">Grant permission</string>
<string name="green">Green</string>
<string name="ham_long_name_summary">Optional. Appended to your call sign, e.g. KD2ABC//Attic Heltec</string>
<string name="hardware">Hardware</string>
<string name="hardware_model">Hardware model</string>
<string name="heading">Heading</string>
@@ -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,
),
@@ -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,
@@ -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,
)
}
@@ -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<User>,
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) },
)
}
}
@@ -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"))
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<User>
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)
}
}