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