feat: upcoming support for tak and trafficmanagement configs, device hw (#4671)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-27 11:44:19 -06:00
committed by GitHub
parent a07992530c
commit b2b21e10e2
33 changed files with 737 additions and 891 deletions

View File

@@ -1349,5 +1349,47 @@
"images": [
"tbeam-1w.svg"
]
},
{
"hwModel": 123,
"hwModelSlug": "T5_S3_EPAPER_PRO",
"platformioTarget": "t5-s3-epaper-pro",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo T5 S3 ePaper Pro",
"tags": [
"LilyGo"
],
"hasMui": true,
"partitionScheme": "8MB"
},
{
"hwModel": 124,
"hwModelSlug": "TBEAM_BPF",
"platformioTarget": "tbeam-bpf",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo T-Beam BPF",
"tags": [
"LilyGo"
],
"hasMui": false,
"partitionScheme": "8MB"
},
{
"hwModel": 125,
"hwModelSlug": "MINI_EPAPER_S3",
"platformioTarget": "mini-epaper-s3",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo T-Mini E-paper S3 Kit",
"tags": [
"LilyGo"
],
"hasMui": true,
"partitionScheme": "8MB"
}
]

View File

@@ -61,7 +61,9 @@ import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@@ -167,6 +169,11 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.TRAFFIC_MANAGEMENT ->
TrafficManagementConfigScreen(viewModel, onBack = navController::popBackStack)
ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}

View File

@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
android { androidResources.enable = false }
android {
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
commonMain.dependencies {

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025-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.database.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.tak_role_forwardobserver
import org.meshtastic.core.resources.tak_role_hq
import org.meshtastic.core.resources.tak_role_k9
import org.meshtastic.core.resources.tak_role_medic
import org.meshtastic.core.resources.tak_role_rto
import org.meshtastic.core.resources.tak_role_sniper
import org.meshtastic.core.resources.tak_role_teamlead
import org.meshtastic.core.resources.tak_role_teammember
import org.meshtastic.core.resources.tak_role_unspecified
import org.meshtastic.core.resources.tak_team_blue
import org.meshtastic.core.resources.tak_team_brown
import org.meshtastic.core.resources.tak_team_cyan
import org.meshtastic.core.resources.tak_team_dark_blue
import org.meshtastic.core.resources.tak_team_dark_green
import org.meshtastic.core.resources.tak_team_green
import org.meshtastic.core.resources.tak_team_magenta
import org.meshtastic.core.resources.tak_team_maroon
import org.meshtastic.core.resources.tak_team_orange
import org.meshtastic.core.resources.tak_team_purple
import org.meshtastic.core.resources.tak_team_red
import org.meshtastic.core.resources.tak_team_teal
import org.meshtastic.core.resources.tak_team_unspecified_color
import org.meshtastic.core.resources.tak_team_white
import org.meshtastic.core.resources.tak_team_yellow
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(team: Team): StringResource = when (team) {
Team.Unspecifed_Color -> Res.string.tak_team_unspecified_color
Team.White -> Res.string.tak_team_white
Team.Yellow -> Res.string.tak_team_yellow
Team.Orange -> Res.string.tak_team_orange
Team.Magenta -> Res.string.tak_team_magenta
Team.Red -> Res.string.tak_team_red
Team.Maroon -> Res.string.tak_team_maroon
Team.Purple -> Res.string.tak_team_purple
Team.Dark_Blue -> Res.string.tak_team_dark_blue
Team.Blue -> Res.string.tak_team_blue
Team.Cyan -> Res.string.tak_team_cyan
Team.Teal -> Res.string.tak_team_teal
Team.Green -> Res.string.tak_team_green
Team.Dark_Green -> Res.string.tak_team_dark_green
Team.Brown -> Res.string.tak_team_brown
}
fun getStringResFrom(role: MemberRole): StringResource = when (role) {
MemberRole.Unspecifed -> Res.string.tak_role_unspecified
MemberRole.TeamMember -> Res.string.tak_role_teammember
MemberRole.TeamLead -> Res.string.tak_role_teamlead
MemberRole.HQ -> Res.string.tak_role_hq
MemberRole.Sniper -> Res.string.tak_role_sniper
MemberRole.Medic -> Res.string.tak_role_medic
MemberRole.ForwardObserver -> Res.string.tak_role_forwardobserver
MemberRole.RTO -> Res.string.tak_role_rto
MemberRole.K9 -> Res.string.tak_role_k9
}
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun getColorFrom(team: Team): Long = when (team) {
Team.Unspecifed_Color -> 0xFF00FFFF // Default to Cyan
Team.White -> 0xFFFFFFFF
Team.Yellow -> 0xFFFFFF00
Team.Orange -> 0xFFFFA500
Team.Magenta -> 0xFFFF00FF
Team.Red -> 0xFFFF0000
Team.Maroon -> 0xFF800000
Team.Purple -> 0xFF800080
Team.Dark_Blue -> 0xFF00008B
Team.Blue -> 0xFF0000FF
Team.Cyan -> 0xFF00FFFF
Team.Teal -> 0xFF008080
Team.Green -> 0xFF00FF00
Team.Dark_Green -> 0xFF006400
Team.Brown -> 0xFFA52A2A
}

View File

@@ -25,6 +25,13 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
kotlin {
@Suppress("UnstableApiUsage")
android {
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
withDeviceTest { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
}
sourceSets {
commonMain.dependencies {
api(projects.core.proto)
@@ -37,9 +44,25 @@ kotlin {
}
androidMain.dependencies {
api(libs.androidx.annotation)
api(libs.androidx.core.ktx)
implementation(libs.zxing.core)
}
commonTest.dependencies { implementation(kotlin("test")) }
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.mockk)
implementation(libs.androidx.test.ext.junit)
implementation(kotlin("test"))
}
}
val androidDeviceTest by getting {
dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.runner)
}
}
}
}

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -25,60 +26,74 @@ class CapabilitiesTest {
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
@Test
fun `canMuteNode requires v2 7 18`() {
fun canMuteNodeRequiresV2718() {
assertFalse(caps("2.7.15").canMuteNode)
assertTrue(caps("2.7.18").canMuteNode)
assertTrue(caps("2.8.0").canMuteNode)
assertTrue(caps("2.8.1").canMuteNode)
}
// FIXME: needs updating when NeighborInfo is working properly
@Test
fun `canRequestNeighborInfo disabled`() {
fun canRequestNeighborInfoIsCurrentlyDisabled() {
assertFalse(caps("2.7.14").canRequestNeighborInfo)
assertFalse(caps("2.7.15").canRequestNeighborInfo)
assertFalse(caps("2.8.0").canRequestNeighborInfo)
assertFalse(caps("3.0.0").canRequestNeighborInfo)
}
@Test
fun `canSendVerifiedContacts requires v2 7 12`() {
fun canSendVerifiedContactsRequiresV2712() {
assertFalse(caps("2.7.11").canSendVerifiedContacts)
assertTrue(caps("2.7.12").canSendVerifiedContacts)
assertTrue(caps("2.7.15").canSendVerifiedContacts)
}
@Test
fun `canToggleTelemetryEnabled requires v2 7 12`() {
fun canToggleTelemetryEnabledRequiresV2712() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
}
@Test
fun `canToggleUnmessageable requires v2 6 9`() {
fun canToggleUnmessageableRequiresV269() {
assertFalse(caps("2.6.8").canToggleUnmessageable)
assertTrue(caps("2.6.9").canToggleUnmessageable)
}
@Test
fun `supportsQrCodeSharing requires v2 6 8`() {
fun supportsQrCodeSharingRequiresV268() {
assertFalse(caps("2.6.7").supportsQrCodeSharing)
assertTrue(caps("2.6.8").supportsQrCodeSharing)
}
@Test
fun `supportsSecondaryChannelLocation requires v2 6 10`() {
fun supportsSecondaryChannelLocationRequiresV2610() {
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
}
@Test
fun `supportsStatusMessage requires v2 7 17`() {
fun supportsStatusMessageRequiresV2717() {
assertFalse(caps("2.7.16").supportsStatusMessage)
assertTrue(caps("2.7.17").supportsStatusMessage)
}
@Test
fun `null firmware returns all false`() {
fun supportsTrafficManagementConfigRequiresV300() {
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
}
@Test
fun supportsTakConfigRequiresV2719() {
assertFalse(caps("2.7.18").supportsTakConfig)
assertTrue(caps("2.7.19").supportsTakConfig)
}
@Test
fun supportsEsp32OtaRequiresV2718() {
assertFalse(caps("2.7.17").supportsEsp32Ota)
assertTrue(caps("2.7.18").supportsEsp32Ota)
}
@Test
fun nullFirmwareReturnsAllFalse() {
val c = caps(null)
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
@@ -88,44 +103,35 @@ class CapabilitiesTest {
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
assertFalse(c.supportsTrafficManagementConfig)
assertFalse(c.supportsTakConfig)
assertFalse(c.supportsEsp32Ota)
}
@Test
fun `invalid firmware returns all false`() {
val c = caps("invalid")
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
assertFalse(c.canSendVerifiedContacts)
assertFalse(c.canToggleTelemetryEnabled)
assertFalse(c.canToggleUnmessageable)
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
}
@Test
fun `forceEnableAll returns true for everything regardless of version`() {
fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.canMuteNode)
assertTrue(c.canRequestNeighborInfo)
assertTrue(c.canSendVerifiedContacts)
assertTrue(c.canToggleTelemetryEnabled)
assertTrue(c.canToggleUnmessageable)
assertTrue(c.supportsQrCodeSharing)
assertTrue(c.supportsSecondaryChannelLocation)
assertTrue(c.supportsStatusMessage)
assertTrue(c.supportsTrafficManagementConfig)
assertTrue(c.supportsTakConfig)
}
@Test
fun `forceEnableAll returns true even for invalid versions`() {
val c = Capabilities(firmwareVersion = "invalid", forceEnableAll = true)
assertTrue(c.canMuteNode)
assertTrue(c.canRequestNeighborInfo)
assertTrue(c.canSendVerifiedContacts)
assertTrue(c.canToggleTelemetryEnabled)
assertTrue(c.canToggleUnmessageable)
assertTrue(c.supportsQrCodeSharing)
assertTrue(c.supportsSecondaryChannelLocation)
assertTrue(c.supportsStatusMessage)
fun deviceVersionParsingIsRobust() {
assertEquals(20712, DeviceVersion("2.7.12").asInt)
assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
assertEquals(30000, DeviceVersion("3.0.0").asInt)
assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
assertEquals(0, DeviceVersion("invalid").asInt)
}
@Test
fun deviceVersionComparisonIsCorrect() {
assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
}
}

View File

@@ -119,24 +119,24 @@ class DataPacketParcelTest {
)
private fun assertDataPacketsEqual(expected: DataPacket, actual: DataPacket) {
assertEquals("to", expected.to, actual.to)
assertEquals("bytes", expected.bytes, actual.bytes)
assertEquals("dataType", expected.dataType, actual.dataType)
assertEquals("from", expected.from, actual.from)
assertEquals("time", expected.time, actual.time)
assertEquals("id", expected.id, actual.id)
assertEquals("status", expected.status, actual.status)
assertEquals("hopLimit", expected.hopLimit, actual.hopLimit)
assertEquals("channel", expected.channel, actual.channel)
assertEquals("wantAck", expected.wantAck, actual.wantAck)
assertEquals("hopStart", expected.hopStart, actual.hopStart)
assertEquals("snr", expected.snr, actual.snr, 0.001f)
assertEquals("rssi", expected.rssi, actual.rssi)
assertEquals("replyId", expected.replyId, actual.replyId)
assertEquals("relayNode", expected.relayNode, actual.relayNode)
assertEquals("relays", expected.relays, actual.relays)
assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt)
assertEquals("emoji", expected.emoji, actual.emoji)
assertEquals("sfppHash", expected.sfppHash, actual.sfppHash)
assertEquals(expected.to, actual.to)
assertEquals(expected.bytes, actual.bytes)
assertEquals(expected.dataType, actual.dataType)
assertEquals(expected.from, actual.from)
assertEquals(expected.time, actual.time)
assertEquals(expected.id, actual.id)
assertEquals(expected.status, actual.status)
assertEquals(expected.hopLimit, actual.hopLimit)
assertEquals(expected.channel, actual.channel)
assertEquals(expected.wantAck, actual.wantAck)
assertEquals(expected.hopStart, actual.hopStart)
assertEquals(expected.snr, actual.snr, 0.001f)
assertEquals(expected.rssi, actual.rssi)
assertEquals(expected.replyId, actual.replyId)
assertEquals(expected.relayNode, actual.relayNode)
assertEquals(expected.relays, actual.relays)
assertEquals(expected.viaMqtt, actual.viaMqtt)
assertEquals(expected.emoji, actual.emoji)
assertEquals(expected.sfppHash, actual.sfppHash)
}
}

View File

@@ -21,6 +21,7 @@ import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -36,7 +37,7 @@ class DataPacketTest {
assertEquals(hash, packet.sfppHash)
val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello")
assertEquals(null, packetNoHash.sfppHash)
assertNull(packetNoHash.sfppHash)
}
@Test

View File

@@ -18,7 +18,7 @@ package org.meshtastic.core.model
import androidx.core.os.LocaleListCompat
import org.junit.After
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.Config
@@ -50,16 +50,16 @@ class NodeInfoTest {
@Test
fun distanceGood() {
Assert.assertEquals(node[1].distance(node[2]), 1111)
Assert.assertEquals(node[1].distance(node[3]), 111)
Assert.assertEquals(node[1].distance(node[4]), 1779)
assertEquals(1111, node[1].distance(node[2]))
assertEquals(111, node[1].distance(node[3]))
assertEquals(1779, node[1].distance(node[4]))
}
@Test
fun distanceStrGood() {
Assert.assertEquals(node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value), "1.1 km")
Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value), "111 m")
Assert.assertEquals(node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "1.1 mi")
Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "364 ft")
assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
}
}

View File

@@ -16,22 +16,23 @@
*/
package org.meshtastic.core.model
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PositionTest {
@Test
fun degGood() {
Assert.assertEquals(Position.degI(89.0), 890000000)
Assert.assertEquals(Position.degI(-89.0), -890000000)
assertEquals(Position.degI(89.0), 890000000)
assertEquals(Position.degI(-89.0), -890000000)
Assert.assertEquals(Position.degD(Position.degI(89.0)), 89.0, 0.01)
Assert.assertEquals(Position.degD(Position.degI(-89.0)), -89.0, 0.01)
assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
}
@Test
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
val position = Position(37.1, 121.1, 35)
Assert.assertTrue(position.time != 0)
assertTrue(position.time != 0)
}
}

View File

@@ -58,7 +58,7 @@ class SharedContactTest {
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
@@ -66,7 +66,7 @@ class SharedContactTest {
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidPathThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
@@ -74,21 +74,21 @@ class SharedContactTest {
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testMissingFragmentThrows() {
val urlStr = "https://meshtastic.org/v/"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidBase64Throws() {
val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidProtoThrows() {
// Tag 0 is invalid in Protobuf
// 0x00 -> Tag 0, Type 0.

View File

@@ -32,7 +32,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles channel share uri`() {
val uri = Uri.parse("https://meshtastic.org/e/somechannel")
val uri = Uri.parse("https://meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle channel URI", handled)
@@ -41,7 +41,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles contact share uri`() {
val uri = Uri.parse("https://meshtastic.org/v/somecontact")
val uri = Uri.parse("https://meshtastic.org/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle contact URI", handled)
@@ -50,21 +50,21 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri ignores other hosts`() {
val uri = Uri.parse("https://example.com/e/somechannel")
val uri = Uri.parse("https://example.com/e/somechannel").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle other hosts", handled)
}
@Test
fun `handleMeshtasticUri ignores other paths`() {
val uri = Uri.parse("https://meshtastic.org/other/path")
val uri = Uri.parse("https://meshtastic.org/other/path").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle unknown paths", handled)
}
@Test
fun `handleMeshtasticUri handles case insensitivity`() {
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle mixed case URI", handled)
@@ -73,7 +73,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles www host`() {
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle www host", handled)
@@ -82,7 +82,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long channel path`() {
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle long channel path", handled)
@@ -91,7 +91,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long contact path`() {
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle long contact path", handled)

View File

@@ -1,100 +0,0 @@
/*
* Copyright (c) 2025-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.util
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Telemetry
class ExtensionsTest {
@Test
fun `isDirectSignal returns true for valid LoRa non-MQTT packets with matching hops`() {
val packet =
MeshPacket(
rx_time = 123456,
hop_start = 3,
hop_limit = 3,
via_mqtt = false,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertTrue(packet.isDirectSignal())
}
@Test
fun `isDirectSignal returns false if via MQTT`() {
val packet =
MeshPacket(
rx_time = 123456,
hop_start = 3,
hop_limit = 3,
via_mqtt = true,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertFalse(packet.isDirectSignal())
}
@Test
fun `isDirectSignal returns false if hops do not match`() {
val packet =
MeshPacket(
rx_time = 123456,
hop_start = 3,
hop_limit = 2,
via_mqtt = false,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertFalse(packet.isDirectSignal())
}
@Test
fun `isDirectSignal returns false if rx_time is zero`() {
val packet =
MeshPacket(
rx_time = 0,
hop_start = 3,
hop_limit = 3,
via_mqtt = false,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertFalse(packet.isDirectSignal())
}
@Test
fun `hasValidEnvironmentMetrics returns true when temperature and humidity are present and valid`() {
val telemetry =
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = 50.0f))
assertTrue(telemetry.hasValidEnvironmentMetrics())
}
@Test
fun `hasValidEnvironmentMetrics returns false if temperature is NaN`() {
val telemetry =
Telemetry(environment_metrics = EnvironmentMetrics(temperature = Float.NaN, relative_humidity = 50.0f))
assertFalse(telemetry.hasValidEnvironmentMetrics())
}
@Test
fun `hasValidEnvironmentMetrics returns false if humidity is missing`() {
val telemetry =
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = null))
assertFalse(telemetry.hasValidEnvironmentMetrics())
}
}

View File

@@ -1,95 +0,0 @@
/*
* Copyright (c) 2025-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.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class SfppHasherTest {
@Test
fun `computeMessageHash produces consistent results`() {
val payload = "Hello World".toByteArray()
val to = 1234
val from = 5678
val id = 999
val hash1 = SfppHasher.computeMessageHash(payload, to, from, id)
val hash2 = SfppHasher.computeMessageHash(payload, to, from, id)
assertArrayEquals(hash1, hash2)
assertEquals(16, hash1.size)
}
@Test
fun `computeMessageHash produces different results for different inputs`() {
val payload = "Hello World".toByteArray()
val to = 1234
val from = 5678
val id = 999
val hashBase = SfppHasher.computeMessageHash(payload, to, from, id)
// Different payload
val hashDiffPayload = SfppHasher.computeMessageHash("Hello Work".toByteArray(), to, from, id)
assertNotEquals(hashBase.toList(), hashDiffPayload.toList())
// Different to
val hashDiffTo = SfppHasher.computeMessageHash(payload, 1235, from, id)
assertNotEquals(hashBase.toList(), hashDiffTo.toList())
// Different from
val hashDiffFrom = SfppHasher.computeMessageHash(payload, to, 5679, id)
assertNotEquals(hashBase.toList(), hashDiffFrom.toList())
// Different id
val hashDiffId = SfppHasher.computeMessageHash(payload, to, from, 1000)
assertNotEquals(hashBase.toList(), hashDiffId.toList())
}
@Test
fun `computeMessageHash handles large values`() {
val payload = byteArrayOf(1, 2, 3)
// Testing that large unsigned-like values don't cause issues
val to = -1 // 0xFFFFFFFF
val from = 0x7FFFFFFF
val id = Int.MIN_VALUE
val hash = SfppHasher.computeMessageHash(payload, to, from, id)
assertEquals(16, hash.size)
}
@Test
fun `computeMessageHash follows little endian for integers`() {
// This test ensures that the hash is computed consistently with the firmware
// which uses little-endian byte order for these fields.
val payload = byteArrayOf()
val to = 0x01020304
val from = 0x05060708
val id = 0x090A0B0C
val hash = SfppHasher.computeMessageHash(payload, to, from, id)
assertNotNull(hash)
assertEquals(16, hash.size)
}
private fun assertNotNull(any: Any?) {
if (any == null) throw AssertionError("Should not be null")
}
}

View File

@@ -1,103 +0,0 @@
/*
* Copyright (c) 2025-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.util
import kotlinx.datetime.TimeZone
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.common.util.await
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.secondsToInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import java.util.concurrent.CountDownLatch
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
class TimeExtensionsTest {
@Test
fun testNowMillis() {
val start = Clock.System.now().toEpochMilliseconds()
val now = nowMillis
val end = Clock.System.now().toEpochMilliseconds()
assertTrue(now in start..end)
}
@Test
fun testNowSeconds() {
val start = Clock.System.now().epochSeconds
val now = nowSeconds
val end = Clock.System.now().epochSeconds
assertTrue(now in start..end)
}
@Test
fun testToDate() {
val instant = Instant.fromEpochMilliseconds(1234567890L)
val date = instant.toDate()
assertEquals(1234567890L, date.time)
}
@Test
fun testLongToInstant() {
val millis = 1234567890L
val instant = millis.toInstant()
assertEquals(millis, instant.toEpochMilliseconds())
}
@Test
fun testIntSecondsToInstant() {
val seconds = 1234567890
val instant = seconds.secondsToInstant()
assertEquals(seconds.toLong(), instant.epochSeconds)
}
@Test
fun testDurationInWholeSeconds() {
assertEquals(60L, 60.seconds.inWholeSeconds)
assertEquals(3600L, TimeConstants.ONE_HOUR.inWholeSeconds)
}
@Test
fun testLongSecondsProperty() {
assertEquals(60.seconds, 60L.seconds)
}
@Test
fun testCountDownLatchAwaitWithDuration() {
val latch = CountDownLatch(1)
// This should timeout quickly
val result = latch.await(10.milliseconds)
assertEquals(false, result)
val latch2 = CountDownLatch(1)
latch2.countDown()
val result2 = latch2.await(1.seconds)
assertEquals(true, result2)
}
@Test
fun testTimeZoneToPosixString() {
val tz = TimeZone.of("UTC")
assertEquals("UTC0", tz.toPosixString())
}
}

View File

@@ -1,118 +0,0 @@
/*
* Copyright (c) 2025-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.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.model.util.UnitConversions.toTempString
class UnitConversionsTest {
// Test data: (celsius, isFahrenheit, expected)
private val tempTestCases =
listOf(
// Issue #4150: negative zero should display as "0"
Triple(-0.1f, false, "0°C"),
Triple(-0.2f, false, "0°C"),
Triple(-0.4f, false, "0°C"),
Triple(-0.49f, false, "0°C"),
// Boundary: -0.5 rounds to -1
Triple(-0.5f, false, "-1°C"),
Triple(-0.9f, false, "-1°C"),
Triple(-1.0f, false, "-1°C"),
// Zero and small positives
Triple(0.0f, false, "0°C"),
Triple(0.1f, false, "0°C"),
Triple(0.4f, false, "0°C"),
// Typical values
Triple(1.0f, false, "1°C"),
Triple(20.0f, false, "20°C"),
Triple(25.4f, false, "25°C"),
Triple(25.5f, false, "26°C"),
// Negative
Triple(-5.0f, false, "-5°C"),
Triple(-10.0f, false, "-10°C"),
Triple(-20.4f, false, "-20°C"),
// Fahrenheit conversions
Triple(0.0f, true, "32°F"),
Triple(20.0f, true, "68°F"),
Triple(25.0f, true, "77°F"),
Triple(100.0f, true, "212°F"),
Triple(-40.0f, true, "-40°F"), // -40°C = -40°F
// Issue #4150: negative zero in Fahrenheit
Triple(-0.1f, true, "32°F"),
Triple(-17.78f, true, "0°F"),
)
@Test
fun `toTempString formats all temperatures correctly`() {
tempTestCases.forEach { (celsius, isFahrenheit, expected) ->
assertEquals(
"Failed for $celsius°C (Fahrenheit=$isFahrenheit)",
expected,
celsius.toTempString(isFahrenheit),
)
}
}
@Test
fun `toTempString handles extreme temperatures`() {
assertEquals("100°C", 100.0f.toTempString(false))
assertEquals("-40°C", (-40.0f).toTempString(false))
assertEquals("-40°F", (-40.0f).toTempString(true))
}
@Test
fun `toTempString handles NaN`() {
assertEquals("--", Float.NaN.toTempString(false))
assertEquals("--", Float.NaN.toTempString(true))
}
@Test
fun `celsiusToFahrenheit converts correctly`() {
mapOf(
0.0f to 32.0f,
20.0f to 68.0f,
100.0f to 212.0f,
-40.0f to -40.0f,
).forEach { (celsius, expectedFahrenheit) ->
assertEquals(expectedFahrenheit, UnitConversions.celsiusToFahrenheit(celsius), 0.01f)
}
}
@Test
fun `calculateDewPoint returns expected values`() {
// At 100% humidity, dew point equals temperature
assertEquals(20.0f, UnitConversions.calculateDewPoint(20.0f, 100.0f), 0.1f)
// Known reference: 20°C at 60% humidity ≈ 12°C dew point
assertEquals(12.0f, UnitConversions.calculateDewPoint(20.0f, 60.0f), 0.5f)
// Higher humidity = higher dew point
val highHumidity = UnitConversions.calculateDewPoint(25.0f, 80.0f)
val lowHumidity = UnitConversions.calculateDewPoint(25.0f, 40.0f)
assertTrue("Dew point should be higher at higher humidity", highHumidity > lowHumidity)
}
@Test
fun `calculateDewPoint handles edge cases`() {
// 0% humidity results in NaN (ln(0) = -Infinity, causing invalid calculation)
val zeroHumidity = UnitConversions.calculateDewPoint(20.0f, 0.0f)
assertTrue("Expected NaN for 0% humidity", zeroHumidity.isNaN())
}
}

View File

@@ -1,336 +0,0 @@
/*
* Copyright (c) 2025-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.util
import co.touchlab.kermit.Logger
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
/**
* Unit tests for Wire extension functions.
*
* Tests safe decoding, size validation, and JSON marshalling extensions to ensure proper error handling and
* functionality.
*/
class WireExtensionsTest {
private val testLogger = Logger
@Before
fun setUp() {
// Setup test logger if needed
}
// ===== decodeOrNull() Tests =====
@Test
fun `decodeOrNull with valid ByteString returns decoded message`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15)
val encoded = Position.ADAPTER.encode(position)
val byteString = encoded.toByteString()
// Act
val decoded = Position.ADAPTER.decodeOrNull(byteString, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(position.latitude_i, decoded!!.latitude_i)
assertEquals(position.longitude_i, decoded.longitude_i)
assertEquals(position.altitude, decoded.altitude)
}
@Test
fun `decodeOrNull with null ByteString returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(null as ByteString?, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with empty ByteString returns empty message`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger)
// Assert
assertNotNull(result)
// An empty position should have null/default values
assertNull(result!!.latitude_i)
}
@Test
fun `decodeOrNull with valid ByteArray returns decoded message`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
val encoded = Position.ADAPTER.encode(position)
// Act
val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(position.latitude_i, decoded!!.latitude_i)
assertEquals(position.longitude_i, decoded.longitude_i)
}
@Test
fun `decodeOrNull with null ByteArray returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(null as ByteArray?, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with empty ByteArray returns empty message`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger)
// Assert
assertNotNull(result)
assertNull(result!!.latitude_i)
}
@Test
fun `decodeOrNull with invalid data returns null`() {
// Arrange
// A single byte 0xFF is an invalid field tag (field 0 is reserved and tags are varints)
val invalidBytes = ByteString.of(0xFF.toByte())
// Act - should not throw, should return null
val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger)
// Assert
assertNull(result)
}
// ===== Size Validation Tests =====
@Test
fun `isWithinSizeLimit returns true for message under limit`() {
// Arrange
val position = Position(latitude_i = 371234567)
val limit = 1000
// Act
val isValid = Position.ADAPTER.isWithinSizeLimit(position, limit)
// Assert
assertTrue(isValid)
}
@Test
fun `isWithinSizeLimit returns false for message over limit`() {
// Arrange
val telemetry =
Telemetry(
device_metrics =
DeviceMetrics(voltage = 4.2f, battery_level = 85, air_util_tx = 5.0f, channel_utilization = 15.0f),
)
val limit = 1 // Artificially low limit
// Act
val isValid = Telemetry.ADAPTER.isWithinSizeLimit(telemetry, limit)
// Assert
assertEquals(false, isValid)
}
@Test
fun `sizeInBytes returns accurate encoded size`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
// Act
val size = Position.ADAPTER.sizeInBytes(position)
val actualEncoded = Position.ADAPTER.encode(position)
// Assert
assertEquals(actualEncoded.size, size)
assertTrue(size > 0)
}
@Test
fun `sizeInBytes for empty message`() {
// Arrange
val emptyPosition = Position()
// Act
val size = Position.ADAPTER.sizeInBytes(emptyPosition)
// Assert
assertTrue(size >= 0)
}
@Test
fun `sizeInBytes matches wire encoding size`() {
// Arrange
val user = User(id = "12345", long_name = "Test User", short_name = "TU")
// Act
val extensionSize = User.ADAPTER.sizeInBytes(user)
val actualEncoded = User.ADAPTER.encode(user)
// Assert
assertEquals(extensionSize, actualEncoded.size)
}
// ===== JSON Marshalling Tests =====
@Test
fun `toReadableString returns non-empty string`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
// Act
val readable = Position.ADAPTER.toReadableString(position)
// Assert
assertNotNull(readable)
assertTrue(readable.isNotEmpty())
assertTrue(readable.contains("Position"))
}
@Test
fun `toReadableString contains field values`() {
// Arrange
val position = Position(latitude_i = 12345, longitude_i = 67890)
// Act
val readable = Position.ADAPTER.toReadableString(position)
// Assert
assertTrue(readable.contains("12345"))
assertTrue(readable.contains("67890"))
}
@Test
fun `toOneLiner returns single line string`() {
// Arrange
val telemetry = Telemetry(device_metrics = DeviceMetrics(voltage = 4.2f))
// Act
val oneLiner = Telemetry.ADAPTER.toOneLiner(telemetry)
// Assert
assertNotNull(oneLiner)
assertEquals(false, oneLiner.contains("\n"))
assertTrue(oneLiner.isNotEmpty())
}
@Test
fun `toOneLiner contains essential data`() {
// Arrange
val user = User(long_name = "Test User")
// Act
val oneLiner = User.ADAPTER.toOneLiner(user)
// Assert
assertTrue(oneLiner.contains("Test User"))
}
// ===== Integration Tests =====
@Test
fun `decode and encode roundtrip maintains data`() {
// Arrange
val originalPosition =
Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15, precision_bits = 5)
val encoded = Position.ADAPTER.encode(originalPosition)
// Act
val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(originalPosition.latitude_i, decoded!!.latitude_i)
assertEquals(originalPosition.longitude_i, decoded.longitude_i)
assertEquals(originalPosition.altitude, decoded.altitude)
assertEquals(originalPosition.precision_bits, decoded.precision_bits)
}
@Test
fun `size checking prevents oversized messages`() {
// Arrange
val position = Position(latitude_i = 123456789, longitude_i = 987654321, altitude = 100)
val maxSize = 5 // Very small limit
// Act
val isValid = Position.ADAPTER.isWithinSizeLimit(position, maxSize)
val actualSize = Position.ADAPTER.sizeInBytes(position)
// Assert
assertEquals(false, isValid)
assertTrue(actualSize > maxSize)
}
@Test
fun `multiple messages with different sizes`() {
// Arrange
val smallUser = User(short_name = "A")
val largeUser = User(long_name = "Very Long Name " + "X".repeat(100))
// Act
val smallSize = User.ADAPTER.sizeInBytes(smallUser)
val largeSize = User.ADAPTER.sizeInBytes(largeUser)
// Assert
assertTrue(smallSize < largeSize)
assertTrue(largeSize > smallSize)
}
@Test
fun `readable string format consistency`() {
// Arrange
val position = Position(latitude_i = 123456)
// Act
val readable1 = Position.ADAPTER.toReadableString(position)
val readable2 = Position.ADAPTER.toReadableString(position)
// Assert
assertEquals(readable1, readable2)
}
@Test
fun `oneLiner format consistency`() {
// Arrange
val user = User(long_name = "Test")
// Act
val line1 = User.ADAPTER.toOneLiner(user)
val line2 = User.ADAPTER.toOneLiner(user)
// Assert
assertEquals(line1, line2)
assertEquals(false, line1.contains("\n"))
}
}

View File

@@ -23,50 +23,56 @@ import org.meshtastic.core.model.util.isDebug
*
* This class provides a centralized way to check if specific features are supported by the connected node's firmware.
* Add new features here to ensure consistency across the app.
*
* Note: Properties are calculated once during initialization for efficiency.
*/
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
private val version = firmwareVersion?.let { DeviceVersion(it) }
private fun isSupported(minVersion: String): Boolean =
forceEnableAll || (version != null && version >= DeviceVersion(minVersion))
private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min)
/**
* Ability to mute notifications from specific nodes via admin messages.
*
* Note: This is currently not available in firmware but defined here for future support.
*/
val canMuteNode: Boolean
get() = isSupported("2.7.18")
/** Ability to mute notifications from specific nodes via admin messages. */
val canMuteNode = atLeast(V2_7_18)
/** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
val canRequestNeighborInfo: Boolean
get() = isSupported("9.9.9")
val canRequestNeighborInfo = atLeast(UNRELEASED)
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
val canSendVerifiedContacts: Boolean
get() = isSupported("2.7.12")
val canSendVerifiedContacts = atLeast(V2_7_12)
/** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
val canToggleTelemetryEnabled: Boolean
get() = isSupported("2.7.12")
val canToggleTelemetryEnabled = atLeast(V2_7_12)
/** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
val canToggleUnmessageable: Boolean
get() = isSupported("2.6.9")
val canToggleUnmessageable = atLeast(V2_6_9)
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
val supportsQrCodeSharing: Boolean
get() = isSupported("2.6.8")
val supportsQrCodeSharing = atLeast(V2_6_8)
/** Support for Status Message module. Supported since firmware v2.7.17. */
val supportsStatusMessage: Boolean
get() = isSupported("2.7.17")
val supportsStatusMessage = atLeast(V2_7_17)
/** Support for Traffic Management module. Supported since firmware v3.0.0. */
val supportsTrafficManagementConfig = atLeast(V3_0_0)
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
val supportsTakConfig = atLeast(V2_7_19)
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
val supportsSecondaryChannelLocation: Boolean
get() = isSupported("2.6.10")
val supportsSecondaryChannelLocation = atLeast(V2_6_10)
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
val supportsEsp32Ota: Boolean
get() = isSupported("2.7.18")
val supportsEsp32Ota = atLeast(V2_7_18)
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")
private val V2_6_10 = DeviceVersion("2.6.10")
private val V2_7_12 = DeviceVersion("2.7.12")
private val V2_7_17 = DeviceVersion("2.7.17")
private val V2_7_18 = DeviceVersion("2.7.18")
private val V2_7_19 = DeviceVersion("2.7.19")
private val V3_0_0 = DeviceVersion("3.0.0")
private val UNRELEASED = DeviceVersion("9.9.9")
}
}

View File

@@ -21,15 +21,15 @@ import co.touchlab.kermit.Logger
/** Provide structured access to parse and compare device version strings */
data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
/** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */
@Suppress("TooGenericExceptionCaught", "SwallowedException")
val asInt
get() =
try {
verStringToInt(asString)
} catch (e: Exception) {
Logger.w { "Exception while parsing version '$asString', assuming version 0" }
0
}
val asInt: Int =
try {
verStringToInt(asString)
} catch (e: Exception) {
Logger.w { "Exception while parsing version '$asString', assuming version 0" }
0
}
/**
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
@@ -51,5 +51,5 @@ data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
}
override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt
override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
}

View File

@@ -145,6 +145,10 @@ object SettingsRoutes {
@Serializable data object StatusMessage : Route
@Serializable data object TrafficManagement : Route
@Serializable data object TAK : Route
// endregion
// region advanced config routes

View File

@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
android { androidResources.enable = true }
android {
androidResources.enable = true
withHostTest { isIncludeAndroidResources = true }
}
sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }
}

View File

@@ -1223,4 +1223,52 @@
<string name="error_invalid_custom_provider">Invalid name, URL template, or local URI for custom tile provider.</string>
<string name="error_provider_exists">A custom tile provider with this name already exists.</string>
<string name="error_copy_mbtiles_failed">Failed to copy MBTiles file to internal storage.</string>
<string name="tak">TAK (ATAK)</string>
<string name="tak_config">TAK Configuration</string>
<string name="tak_team">Team Color</string>
<string name="tak_role">Member Role</string>
<string name="tak_team_unspecified_color">Unspecified</string>
<string name="tak_team_white">White</string>
<string name="tak_team_yellow">Yellow</string>
<string name="tak_team_orange">Orange</string>
<string name="tak_team_magenta">Magenta</string>
<string name="tak_team_red">Red</string>
<string name="tak_team_maroon">Maroon</string>
<string name="tak_team_purple">Purple</string>
<string name="tak_team_dark_blue">Dark Blue</string>
<string name="tak_team_blue">Blue</string>
<string name="tak_team_cyan">Cyan</string>
<string name="tak_team_teal">Teal</string>
<string name="tak_team_green">Green</string>
<string name="tak_team_dark_green">Dark Green</string>
<string name="tak_team_brown">Brown</string>
<string name="tak_role_unspecified">Unspecified</string>
<string name="tak_role_teammember">Team Member</string>
<string name="tak_role_teamlead">Team Lead</string>
<string name="tak_role_hq">Headquarters</string>
<string name="tak_role_sniper">Sniper</string>
<string name="tak_role_medic">Medic</string>
<string name="tak_role_forwardobserver">Forward Observer</string>
<string name="tak_role_rto">Radio Telephone Operator</string>
<string name="tak_role_k9">Doggo (K9)</string>
<string name="traffic_management">Traffic Management</string>
<string name="traffic_management_config">Traffic Management Configuration</string>
<string name="traffic_management_enabled">Module Enabled</string>
<string name="traffic_management_position_dedup">Position Deduplication</string>
<string name="traffic_management_position_precision">Position Precision (bits)</string>
<string name="traffic_management_position_min_interval">Min Position Interval (secs)</string>
<string name="traffic_management_nodeinfo_direct_response">NodeInfo Direct Response</string>
<string name="traffic_management_nodeinfo_direct_response_max_hops">Max Hops for Direct Response</string>
<string name="traffic_management_rate_limit_enabled">Rate Limiting</string>
<string name="traffic_management_rate_limit_window">Rate Limit Window (secs)</string>
<string name="traffic_management_rate_limit_max_packets">Max Packets in Window</string>
<string name="traffic_management_drop_unknown_enabled">Drop Unknown Packets</string>
<string name="traffic_management_unknown_packet_threshold">Unknown Packet Threshold</string>
<string name="traffic_management_exhaust_hop_telemetry">Local-only Telemetry (Relays)</string>
<string name="traffic_management_exhaust_hop_position">Local-only Position (Relays)</string>
<string name="traffic_management_router_preserve_hops">Preserve Router Hops</string>
</resources>

View File

@@ -38,6 +38,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -51,6 +53,7 @@ fun <T : Enum<T>> DropDownPreference(
modifier: Modifier = Modifier,
summary: String? = null,
itemIcon: @Composable ((T) -> ImageVector)? = null,
itemColor: @Composable ((T) -> Color)? = null,
itemLabel: @Composable ((T) -> String)? = null,
) {
val enumConstants =
@@ -63,7 +66,8 @@ fun <T : Enum<T>> DropDownPreference(
enumConstants.map {
val label = itemLabel?.invoke(it) ?: it.name
val icon = itemIcon?.invoke(it)
DropDownItem(it, label, icon)
val color = itemColor?.invoke(it)
DropDownItem(it, label, icon, color)
}
DropDownPreference(
@@ -77,7 +81,7 @@ fun <T : Enum<T>> DropDownPreference(
)
}
data class DropDownItem<T>(val value: T, val label: String, val icon: ImageVector? = null)
data class DropDownItem<T>(val value: T, val label: String, val icon: ImageVector? = null, val color: Color? = null)
@JvmName("DropDownPreferencePairs")
@Composable
@@ -141,7 +145,17 @@ fun <T> DropDownPreference(
modifier = Modifier.size(24.dp),
)
}
},
}
?: currentItem?.color?.let {
{
Icon(
painter = ColorPainter(it),
contentDescription = currentItem.label,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
}
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors(),
enabled = enabled,
@@ -157,8 +171,20 @@ fun <T> DropDownPreference(
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
selectionOption.icon?.let {
Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp))
if (selectionOption.icon != null) {
Icon(
imageVector = selectionOption.icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
} else if (selectionOption.color != null) {
Icon(
painter = ColorPainter(selectionOption.color),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
Spacer(modifier = Modifier.width(12.dp))
}
Text(selectionOption.label)

View File

@@ -49,8 +49,11 @@ import org.meshtastic.core.resources.remote_hardware
import org.meshtastic.core.resources.serial
import org.meshtastic.core.resources.status_message
import org.meshtastic.core.resources.store_forward
import org.meshtastic.core.resources.tak
import org.meshtastic.core.resources.telemetry
import org.meshtastic.core.resources.traffic_management
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
enum class ModuleRoute(
@@ -59,6 +62,7 @@ enum class ModuleRoute(
val icon: ImageVector?,
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true },
) {
MQTT(Res.string.mqtt, SettingsRoutes.MQTT, Icons.Rounded.Cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -140,18 +144,51 @@ enum class ModuleRoute(
AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG.value,
isSupported = { it.supportsStatusMessage },
),
TRAFFIC_MANAGEMENT(
Res.string.traffic_management,
SettingsRoutes.TrafficManagement,
Icons.Rounded.Speed,
AdminMessage.ModuleConfigType.TRAFFICMANAGEMENT_CONFIG.value,
isSupported = { it.supportsTrafficManagementConfig },
),
TAK(
Res.string.tak,
SettingsRoutes.TAK,
Icons.Rounded.People,
AdminMessage.ModuleConfigType.TAK_CONFIG.value,
isSupported = { it.supportsTakConfig },
isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER },
),
;
val bitfield: Int
get() = 1 shl ordinal
get() =
when (this) {
MQTT -> 0x0001
SERIAL -> 0x0002
EXT_NOTIFICATION -> 0x0004
STORE_FORWARD -> 0x0008
RANGE_TEST -> 0x0010
TELEMETRY -> 0x0020
CANNED_MESSAGE -> 0x0040
AUDIO -> 0x0080
REMOTE_HARDWARE -> 0x0100
NEIGHBOR_INFO -> 0x0200
AMBIENT_LIGHTING -> 0x0400
DETECTION_SENSOR -> 0x0800
PAXCOUNTER -> 0x1000
STATUS_MESSAGE -> 0x0000 // Not excludable yet
TRAFFIC_MANAGEMENT -> 0x0000 // Not excludable yet
TAK -> 0x0000 // Not excludable yet
}
companion object {
fun filterExcludedFrom(metadata: DeviceMetadata?): List<ModuleRoute> {
fun filterExcludedFrom(metadata: DeviceMetadata?, role: Config.DeviceConfig.Role?): List<ModuleRoute> {
val capabilities = Capabilities(metadata?.firmware_version)
return entries.filter {
val excludedModules = metadata?.excluded_modules ?: 0
val isExcluded = (excludedModules and it.bitfield) != 0
!isExcluded && it.isSupported(capabilities)
!isExcluded && it.isSupported(capabilities) && it.isApplicable(role)
}
}
}

View File

@@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.Download
@@ -97,13 +95,15 @@ fun RadioConfigItemList(
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) }
var modules by remember {
mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
}
LaunchedEffect(excludedModulesUnlocked) {
LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
if (excludedModulesUnlocked) {
modules = ModuleRoute.entries
} else {
modules = ModuleRoute.filterExcludedFrom(state.metadata)
modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
}
}

View File

@@ -355,6 +355,8 @@ constructor(
detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage,
traffic_management = config.traffic_management ?: state.moduleConfig.traffic_management,
tak = config.tak ?: state.moduleConfig.tak,
),
)
}
@@ -591,6 +593,8 @@ constructor(
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
meshService?.commitEditSettings(destNum)
}
@@ -823,6 +827,9 @@ constructor(
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
traffic_management =
response.traffic_management ?: state.moduleConfig.traffic_management,
tak = response.tak ?: state.moduleConfig.tak,
),
)
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025-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.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.getColorFrom
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.tak
import org.meshtastic.core.resources.tak_config
import org.meshtastic.core.resources.tak_role
import org.meshtastic.core.resources.tak_team
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun TAKConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig()
val formState = rememberConfigState(initialValue = takConfig)
LaunchedEffect(takConfig) { formState.value = takConfig }
RadioConfigScreenList(
title = stringResource(Res.string.tak),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = ModuleConfig(tak = it)
viewModel.setModuleConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.tak_config)) {
DropDownPreference(
title = stringResource(Res.string.tak_team),
enabled = state.connected,
selectedItem = formState.value.team,
itemLabel = { stringResource(getStringResFrom(it)) },
itemColor = { Color(getColorFrom(it)) },
onItemSelected = { formState.value = formState.value.copy(team = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.tak_role),
enabled = state.connected,
selectedItem = formState.value.role,
itemLabel = { stringResource(getStringResFrom(it)) },
onItemSelected = { formState.value = formState.value.copy(role = it) },
)
}
}
}
}

View File

@@ -0,0 +1,208 @@
/*
* Copyright (c) 2025-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.foundation.text.KeyboardActions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.traffic_management
import org.meshtastic.core.resources.traffic_management_config
import org.meshtastic.core.resources.traffic_management_drop_unknown_enabled
import org.meshtastic.core.resources.traffic_management_enabled
import org.meshtastic.core.resources.traffic_management_exhaust_hop_position
import org.meshtastic.core.resources.traffic_management_exhaust_hop_telemetry
import org.meshtastic.core.resources.traffic_management_nodeinfo_direct_response
import org.meshtastic.core.resources.traffic_management_nodeinfo_direct_response_max_hops
import org.meshtastic.core.resources.traffic_management_position_dedup
import org.meshtastic.core.resources.traffic_management_position_min_interval
import org.meshtastic.core.resources.traffic_management_position_precision
import org.meshtastic.core.resources.traffic_management_rate_limit_enabled
import org.meshtastic.core.resources.traffic_management_rate_limit_max_packets
import org.meshtastic.core.resources.traffic_management_rate_limit_window
import org.meshtastic.core.resources.traffic_management_router_preserve_hops
import org.meshtastic.core.resources.traffic_management_unknown_packet_threshold
import org.meshtastic.core.ui.component.EditTextPreference
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.ModuleConfig
@Suppress("LongMethod")
@Composable
fun TrafficManagementConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val tmConfig = state.moduleConfig.traffic_management ?: ModuleConfig.TrafficManagementConfig()
val formState = rememberConfigState(initialValue = tmConfig)
val focusManager = LocalFocusManager.current
LaunchedEffect(tmConfig) { formState.value = tmConfig }
RadioConfigScreenList(
title = stringResource(Res.string.traffic_management),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = ModuleConfig(traffic_management = it)
viewModel.setModuleConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.traffic_management_config)) {
SwitchPreference(
title = stringResource(Res.string.traffic_management_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_position_dedup),
checked = formState.value.position_dedup_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(position_dedup_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.traffic_management_position_precision),
value = formState.value.position_precision_bits,
enabled = state.connected,
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
),
onValueChanged = { formState.value = formState.value.copy(position_precision_bits = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.traffic_management_position_min_interval),
value = formState.value.position_min_interval_secs,
enabled = state.connected,
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
),
onValueChanged = { formState.value = formState.value.copy(position_min_interval_secs = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_nodeinfo_direct_response),
checked = formState.value.nodeinfo_direct_response,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(nodeinfo_direct_response = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.traffic_management_nodeinfo_direct_response_max_hops),
value = formState.value.nodeinfo_direct_response_max_hops,
enabled = state.connected,
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
),
onValueChanged = { formState.value = formState.value.copy(nodeinfo_direct_response_max_hops = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_rate_limit_enabled),
checked = formState.value.rate_limit_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(rate_limit_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.traffic_management_rate_limit_window),
value = formState.value.rate_limit_window_secs,
enabled = state.connected,
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
),
onValueChanged = { formState.value = formState.value.copy(rate_limit_window_secs = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.traffic_management_rate_limit_max_packets),
value = formState.value.rate_limit_max_packets,
enabled = state.connected,
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
),
onValueChanged = { formState.value = formState.value.copy(rate_limit_max_packets = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_drop_unknown_enabled),
checked = formState.value.drop_unknown_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(drop_unknown_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.traffic_management_unknown_packet_threshold),
value = formState.value.unknown_packet_threshold,
enabled = state.connected,
keyboardActions =
KeyboardActions(
onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
),
onValueChanged = { formState.value = formState.value.copy(unknown_packet_threshold = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_exhaust_hop_telemetry),
checked = formState.value.exhaust_hop_telemetry,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(exhaust_hop_telemetry = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_exhaust_hop_position),
checked = formState.value.exhaust_hop_position,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(exhaust_hop_position = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.traffic_management_router_preserve_hops),
checked = formState.value.router_preserve_hops,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(router_preserve_hops = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
}