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)
+ }
+}