From b2b21e10e26b2dae4e80570ea4ae32cb83404647 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:44:19 -0600
Subject: [PATCH] feat: upcoming support for tak and trafficmanagement configs,
device hw (#4671)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/src/main/assets/device_hardware.json | 42 +++
.../mesh/navigation/SettingsNavigation.kt | 7 +
core/common/build.gradle.kts | 5 +-
.../org/meshtastic/core/database/model/TAK.kt | 96 +++++
core/model/build.gradle.kts | 23 ++
.../org/meshtastic/core/model/ChannelTest.kt | 0
.../core/model/util/ChannelSetTest.kt | 0
.../core/model/util/SharedContactTest.kt | 0
.../meshtastic/core/model/CapabilitiesTest.kt | 92 ++---
.../core/model/ChannelOptionTest.kt | 0
.../core/model/DataPacketParcelTest.kt | 38 +-
.../meshtastic/core/model/DataPacketTest.kt | 3 +-
.../core/model/DeviceVersionTest.kt | 0
.../org/meshtastic/core/model/NodeInfoTest.kt | 16 +-
.../org/meshtastic/core/model/PositionTest.kt | 13 +-
.../core/model/util/SharedContactTest.kt | 10 +-
.../core/model/util/UriUtilsTest.kt | 16 +-
.../core/model/util/ExtensionsTest.kt | 100 ------
.../core/model/util/SfppHasherTest.kt | 95 -----
.../core/model/util/TimeExtensionsTest.kt | 103 ------
.../core/model/util/UnitConversionsTest.kt | 118 ------
.../core/model/util/WireExtensionsTest.kt | 336 ------------------
.../org/meshtastic/core/model/Capabilities.kt | 56 +--
.../meshtastic/core/model/DeviceVersion.kt | 18 +-
.../org/meshtastic/core/navigation/Routes.kt | 4 +
core/resources/build.gradle.kts | 5 +-
.../composeResources/values/strings.xml | 48 +++
.../core/ui/component/DropDownPreference.kt | 36 +-
.../settings/navigation/ModuleRoute.kt | 43 ++-
.../feature/settings/radio/RadioConfig.kt | 10 +-
.../settings/radio/RadioConfigViewModel.kt | 7 +
.../radio/component/TAKConfigItemList.kt | 80 +++++
.../TrafficManagementConfigItemList.kt | 208 +++++++++++
33 files changed, 737 insertions(+), 891 deletions(-)
create mode 100644 core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/ChannelTest.kt (100%)
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt (100%)
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt (56%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt (76%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DataPacketTest.kt (98%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/NodeInfoTest.kt (73%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/PositionTest.kt (72%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (92%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt (97%)
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 0699ff16b..71143aa72 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -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"
}
]
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
index aa498f009..18522c531 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
@@ -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)
}
}
}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 8f55e26fc..41a0c8a3d 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
- android { androidResources.enable = false }
+ android {
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ }
sourceSets {
commonMain.dependencies {
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
new file mode 100644
index 000000000..bf5cddffc
--- /dev/null
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
@@ -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 .
+ */
+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
+}
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 902098124..951403976 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -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)
+ }
+ }
}
}
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
similarity index 56%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
index e1ffb313a..40f35ece2 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
@@ -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"))
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
similarity index 100%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
similarity index 76%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
index 94bf4f5a4..0d6d15c1d 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
@@ -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)
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
similarity index 98%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
index 5dddd5858..5858585b4 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
@@ -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
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
similarity index 100%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
similarity index 73%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
index 0d10a6426..22942787a 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
@@ -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))
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
similarity index 72%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
index f07ad83dd..e6b44cd27 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
@@ -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)
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 92%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
index c73a65853..67df45ce7 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
@@ -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.
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
similarity index 97%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
index 2c729b1ba..606dc485d 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
@@ -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)
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
deleted file mode 100644
index ae4690a52..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
+++ /dev/null
@@ -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 .
- */
-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())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
deleted file mode 100644
index 218955a2f..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
+++ /dev/null
@@ -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 .
- */
-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")
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
deleted file mode 100644
index 68ea8032e..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
+++ /dev/null
@@ -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 .
- */
-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())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
deleted file mode 100644
index 07832a903..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
+++ /dev/null
@@ -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 .
- */
-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())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
deleted file mode 100644
index b9ede858f..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
+++ /dev/null
@@ -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 .
- */
-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"))
- }
-}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index e5c069fc9..65096604f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -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")
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
index 64d210f5d..d72d7775f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
@@ -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 {
+ /** 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 {
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)
}
diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
index d3a43e392..7aba5f310 100644
--- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -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
diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts
index 347c9d69a..b2e255c4a 100644
--- a/core/resources/build.gradle.kts
+++ b/core/resources/build.gradle.kts
@@ -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")) } }
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 7376bd0a0..b77231ac7 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1223,4 +1223,52 @@
Invalid name, URL template, or local URI for custom tile provider.
A custom tile provider with this name already exists.
Failed to copy MBTiles file to internal storage.
+
+ TAK (ATAK)
+ TAK Configuration
+ Team Color
+ Member Role
+
+ Unspecified
+ White
+ Yellow
+ Orange
+ Magenta
+ Red
+ Maroon
+ Purple
+ Dark Blue
+ Blue
+ Cyan
+ Teal
+ Green
+ Dark Green
+ Brown
+
+ Unspecified
+ Team Member
+ Team Lead
+ Headquarters
+ Sniper
+ Medic
+ Forward Observer
+ Radio Telephone Operator
+ Doggo (K9)
+
+ Traffic Management
+ Traffic Management Configuration
+ Module Enabled
+ Position Deduplication
+ Position Precision (bits)
+ Min Position Interval (secs)
+ NodeInfo Direct Response
+ Max Hops for Direct Response
+ Rate Limiting
+ Rate Limit Window (secs)
+ Max Packets in Window
+ Drop Unknown Packets
+ Unknown Packet Threshold
+ Local-only Telemetry (Relays)
+ Local-only Position (Relays)
+ Preserve Router Hops
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index f6b5e6e64..33a454635 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -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 > 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 > 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 > DropDownPreference(
)
}
-data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null)
+data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null, val color: Color? = null)
@JvmName("DropDownPreferencePairs")
@Composable
@@ -141,7 +145,17 @@ fun 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 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)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
index cb96d573b..fd7eae24c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
@@ -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 {
+ fun filterExcludedFrom(metadata: DeviceMetadata?, role: Config.DeviceConfig.Role?): List {
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)
}
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index e220b5c82..d84cad310 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -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)
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 3f9adf6ee..ec9d29c5c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -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,
),
)
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
new file mode 100644
index 000000000..94b17c645
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -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 .
+ */
+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) },
+ )
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
new file mode 100644
index 000000000..c05ff42d1
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
@@ -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 .
+ */
+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,
+ )
+ }
+ }
+ }
+}