mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-14 22:30:35 -04:00
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:
1 parent
dbf77b1e45
commit
bfcdb2f201
12 files changed
+528
-42
No files matched your search
Generated
+1
@@ -832,6 +832,7 @@ gps_receive_gpio
|
||||
gps_transmit_gpio
|
||||
grant_permission
|
||||
green
|
||||
ham_long_name_summary
|
||||
hardware
|
||||
hardware_model
|
||||
heading
|
||||
|
||||
+2
-2
@@ -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(""))
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -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>
|
||||
|
||||
+2
-1
@@ -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,
|
||||
),
|
||||
|
||||
+29
-1
@@ -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,
|
||||
|
||||
+5
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+99
-29
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
@@ -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"))
|
||||
|
||||
+121
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user