mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
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:
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
@Suppress("UnstableApiUsage")
|
||||
android { androidResources.enable = false }
|
||||
android {
|
||||
androidResources.enable = false
|
||||
withHostTest { isIncludeAndroidResources = true }
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")) } }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user