Brownfield gap remediation: 28 tasks + intro commonMain migration (#5401)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-11 15:10:23 -05:00
committed by GitHub
parent 6eacee626b
commit 95c3bc0bce
72 changed files with 4408 additions and 246 deletions

View File

@@ -3,6 +3,86 @@
# Do NOT edit or remove previous entries — stale state claims cause agent confusion.
# Format: ## YYYY-MM-DD — <summary>
## 2026-05-11 — Migrated feature/intro UI to commonMain
- Moved intro onboarding UI composables and nav graph from `feature/intro/src/androidMain/` into `feature/intro/src/commonMain/`, adding shared `IntroPermissions` and `IntroSettingsNavigator` interfaces plus a common `introGraph` Navigation 3 extension.
- Refactored `AppIntroductionScreen` into a thin Android host that provides Android permission/settings adapters via composition locals, and added `AndroidIntroPermissions`, `AndroidIntroSettingsNavigator`, and JVM desktop no-op stubs.
- Verified with `./gradlew spotlessApply :feature:intro:compileKotlinJvm :feature:intro:compileAndroidMain`.
## 2026-05-11 — Added Esp32OtaUpdateHandler common tests
- Created `feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt`.
- Covered WiFi OTA success flow, download/upload progress reporting, connection-drop error handling, hash rejection, verification timeout, and cancellation propagation.
- Validation note: per task instruction, no Gradle commands were run.
## 2026-05-11 — Added profile import/export round-trip coverage
- Created `feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt`.
- Covered `RadioConfigViewModel.exportProfile()``importProfile()` round trips using the real `ExportProfileUseCase` and `ImportProfileUseCase` with an in-memory `FileService` test double.
- Added representative, empty, and partially populated `DeviceProfile` cases, asserting message equality and stable protobuf bytes across re-export.
- Validation note: per task instruction, no Gradle commands were run.
## 2026-05-11 — Added DirectRadioControllerImpl common tests
- Created `core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt`.
- Covered service-repository flow delegation, send message/send shared contact behavior, remote config request delegation, location stop, and device address updates.
- Validation note: `./gradlew --no-configuration-cache :core:service:allTests` is currently blocked by pre-existing compile failures in `core/network` (`MQTTRepositoryImpl` unresolved `KEEPALIVE_SECONDS`) and downstream `core/data` unresolved `org.meshtastic.core.network` symbols.
## 2026-05-11 — Added DatabaseManager withDb retry host test
- Created `core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/DatabaseManagerWithDbRetryTest.kt`.
- Covered the concurrent `withDb()` retry path by pausing an in-flight query, switching to a new DB, closing the old pool, and asserting the retried query succeeds against the new DB.
- Verified with `./gradlew --no-configuration-cache :core:database:spotlessApply :core:database:testAndroidHostTest --tests "org.meshtastic.core.database.DatabaseManagerWithDbRetryTest"`
and `./gradlew --no-configuration-cache :core:database:spotlessCheck :core:database:testAndroidHostTest`.
## 2026-05-11 — Expanded MQTT repository coverage
- Extended `core/network/src/commonTest/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImplTest.kt`
with topic construction, JSON/protobuf decoding, reconnect retry, subscription retry, and connection-state coverage.
- Added internal `MqttClientSession` / `MqttClientSetup` test hook plus `updateConnectionState()` in
`core/network/src/commonMain/kotlin/org/meshtastic/core/network/repository/MQTTRepositoryImpl.kt` to exercise repository behavior without a real broker.
- Verified with `./gradlew --no-configuration-cache :core:network:allTests`.
## 2026-05-11 — Added RadioConfigViewModel MQTT probe tests
- Extended `feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt`
with MQTT probe success, timeout, thrown-exception-to-Other, and clear/reset coverage.
- Verified with `./gradlew --no-configuration-cache :feature:settings:jvmTest --tests "org.meshtastic.feature.settings.radio.RadioConfigViewModelTest"`.
## 2026-05-11 — Added MeshRouterImpl accessor routing tests
- Created `core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshRouterImplTest.kt`.
- Covered lazy routing access for action-handler send/request/admin calls, traceroute handler access, and service-action passthrough.
- Verified with `./gradlew --no-configuration-cache :core:data:allTests`.
## 2026-05-11 — Added SettingsViewModel saveDataCsv coverage
- Extended `feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt`
with `saveDataCsv writes filtered export via file service`.
- The new test seeds `FakeNodeRepository` + `FakeMeshLogRepository`, captures the `FileService.write()`
sink with Mokkery, and verifies filtered CSV output from the real `ExportDataUseCase`.
- Verified with `./gradlew --no-configuration-cache :feature:settings:jvmTest --tests "org.meshtastic.feature.settings.SettingsViewModelTest"`
after running `:feature:settings:spotlessApply`.
## 2026-05-11 — Added CompassViewModel accuracy edge-case tests
- Extended `feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt`
with PDOP-only, HDOP+VDOP, HDOP-only, precision-bits fallback, missing accuracy metadata,
zero-distance angular error, and very-small-distance angular error coverage.
- Validation note: `:feature:node:allTests` still fails on the pre-existing
`MetricsViewModelTest.saveEnvironmentMetricsCSV writes correct data` Turbine timeout in JVM and Android host tests.
The new CompassViewModel tests pass in the same run.
## 2026-05-11 — Added Node domain model tests
- Created `core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt`.
- Covered `isOnline`, `distance`, `bearing`, `colors`, `createFallback`, `getRelayNode`, `isUnknownUser`, `validPosition`, `hasPKC`, and `mismatchKey`.
- Validation blockers: `:core:model:allTests` currently fails on pre-existing `DataPacketTest` iOS compile errors, and direct `NodeTest` execution hits an existing class-version mismatch in `core:common` helpers.
## 2026-05-11 — Added HeartbeatSender transport tests
- Created `core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/HeartbeatSenderTest.kt`.
- Covered encoded heartbeat payloads, nonce sequencing, interval-driven scheduling, cancellation, zero-interval behavior, and restart semantics using coroutine virtual time.
- Verified with `./gradlew --console=plain --no-configuration-cache :core:network:allTests`.
## 2026-05-11 — Added BaseMapViewModel waypoint expiration tests
- Extended `feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt`.
- Added coverage for future, boundary (`expire == now`), never-expiring (`expire == 0`), and mixed waypoint filtering.
- Verified with `./gradlew --no-daemon --no-configuration-cache :feature:map:spotlessCheck :feature:map:allTests`.
## 2026-05-03 — Switched Gradle GC to G1GC
- Replaced `-XX:+UseZGC` with `-XX:+UseG1GC` in `gradle.properties` to resolve "not supported" error.
- Added `-XX:+ParallelRefProcEnabled` for better build performance.
- Verified with Gradle sync.
## 2026-05-02 — CI cost-control PR review fixes
- Applied PR review feedback: encoding fixes in sort-strings.py, NUL-delimited staged-files loop
in ai-guardrail.sh, installation instructions added, typo fix in strings.xml, command order

View File

@@ -707,6 +707,7 @@ mute_add
mute_always
mute_notifications
mute_remove
mute_selected
mute_status_always
mute_status_muted_for_days
mute_status_muted_for_hours
@@ -1103,6 +1104,7 @@ store_forward
store_forward_config
store_forward_enabled
subnet
success
super_deep_sleep_duration_seconds
supported
supported_by_community
@@ -1212,6 +1214,7 @@ unknown_username
unmessageable
unmonitored_or_infrastructure
unmute
unmute_selected
unrecognized
unset
up_down_select_input_enabled
@@ -1268,6 +1271,7 @@ wifi_provision_connect_failed
wifi_provision_description
wifi_provision_device_found
wifi_provision_device_found_detail
wifi_provision_hidden_network
wifi_provision_mpwrd_disclaimer
wifi_provision_no_networks
wifi_provision_scan_failed

View File

@@ -22,6 +22,9 @@ import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import dev.mokkery.gradle.MokkeryGradleExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
import org.gradle.jvm.toolchain.JavaToolchainService
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.withType
@@ -265,4 +268,16 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
}
}
}
// Published modules compile to JVM 17 for binary compatibility, but their test runtime
// classpath includes non-published dependencies compiled to JVM 21. Override the test
// launcher to JDK 21 so the JVM can load all class file versions at runtime.
if (isPublishedModule) {
val toolchains = extensions.getByType(JavaToolchainService::class.java)
tasks.withType<Test>().configureEach {
javaLauncher.set(
toolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(APP_JDK)) }
)
}
}
}

View File

@@ -16,44 +16,63 @@
*/
package org.meshtastic.core.ble
import com.juul.kable.Advertisement
import com.juul.kable.Scanner
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import kotlin.time.Duration
import kotlin.uuid.Uuid
internal sealed interface KableScanFilter {
data object None : KableScanFilter
data class Address(val value: String) : KableScanFilter
data class ServiceUuid(val value: Uuid) : KableScanFilter
}
internal data class KableScanResult(val identifier: String, val name: String?, val advertisement: Advertisement?)
internal fun resolveKableScanFilter(serviceUuid: Uuid?, address: String?): KableScanFilter = when {
address != null -> KableScanFilter.Address(address)
serviceUuid != null -> KableScanFilter.ServiceUuid(serviceUuid)
else -> KableScanFilter.None
}
private fun Advertisement.toScanResult(): KableScanResult =
KableScanResult(identifier = identifier.toString(), name = name, advertisement = this)
@Single(binds = [BleScanner::class])
class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleScanner {
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
open class KableBleScanner(private val loggingConfig: BleLoggingConfig) : BleScanner {
internal open fun advertisements(filter: KableScanFilter): Flow<KableScanResult> {
val scanner = Scanner {
logging { applyConfig(loggingConfig) }
// When both address and serviceUuid are provided, use OR-semantics so the device
// is found even if one filter is ineffective on the current platform (e.g.
// CoreBluetooth may not re-report a cached identifier via the address filter).
if (address != null && serviceUuid != null) {
filters {
match { this.address = address }
match { services = listOf(serviceUuid) }
}
} else if (address != null) {
filters { match { this.address = address } }
} else if (serviceUuid != null) {
filters { match { services = listOf(serviceUuid) } }
when (filter) {
KableScanFilter.None -> Unit
is KableScanFilter.Address -> filters { match { address = filter.value } }
is KableScanFilter.ServiceUuid -> filters { match { services = listOf(filter.value) } }
}
}
return scanner.advertisements.map(Advertisement::toScanResult)
}
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
val filter = resolveKableScanFilter(serviceUuid = serviceUuid, address = address)
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
return channelFlow {
withTimeoutOrNull(timeout) {
scanner.advertisements.collect { advertisement ->
advertisements(filter).collect { advertisement ->
send(
MeshtasticBleDevice(
address = advertisement.identifier.toString(),
address = advertisement.identifier,
name = advertisement.name,
advertisement = advertisement,
advertisement = advertisement.advertisement,
),
)
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import com.juul.kable.Advertisement
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertSame
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class)
class KableBleConnectionTest {
@Test
fun `scan emits ble device for discovered advertisement`() = runTest {
val advertisement: Advertisement = mock(MockMode.autofill)
val scanner =
TestKableBleScanner(
scanResults =
flowOf(
KableScanResult(
identifier = "AA:BB:CC:DD:EE:FF",
name = "Meshtastic",
advertisement = advertisement,
),
),
)
val result = scanner.scan(timeout = 1.seconds).first()
val device = assertIs<MeshtasticBleDevice>(result)
assertEquals("AA:BB:CC:DD:EE:FF", device.address)
assertEquals("Meshtastic", device.name)
assertSame(advertisement, device.advertisement)
}
@Test
fun `timeout terminates scan`() = runTest {
var cancelled = false
val scanner =
TestKableBleScanner(
scanResults =
flow {
try {
awaitCancellation()
} finally {
cancelled = true
}
},
)
val collected = mutableListOf<BleDevice>()
val job = backgroundScope.launch { scanner.scan(timeout = 1.seconds).toList(collected) }
advanceTimeBy(1.seconds.inWholeMilliseconds + 1)
advanceUntilIdle()
job.join()
assertTrue(job.isCompleted)
assertTrue(cancelled)
assertTrue(collected.isEmpty())
}
@Test
fun `service uuid filter is applied`() = runTest {
val serviceUuid = Uuid.parse("12345678-1234-1234-1234-1234567890ab")
val scanner = TestKableBleScanner(scanResults = emptyFlow())
scanner.scan(timeout = 1.seconds, serviceUuid = serviceUuid).toList()
assertEquals(KableScanFilter.ServiceUuid(serviceUuid), scanner.lastFilter)
}
@Test
fun `address filter is applied`() = runTest {
val scanner = TestKableBleScanner(scanResults = emptyFlow())
scanner.scan(timeout = 1.seconds, address = "AA:BB:CC:DD:EE:FF").toList()
assertEquals(KableScanFilter.Address("AA:BB:CC:DD:EE:FF"), scanner.lastFilter)
}
@Test
fun `address filter takes priority over service uuid`() = runTest {
val serviceUuid = Uuid.parse("12345678-1234-1234-1234-1234567890ab")
val scanner = TestKableBleScanner(scanResults = emptyFlow())
scanner.scan(timeout = 1.seconds, serviceUuid = serviceUuid, address = "AA:BB:CC:DD:EE:FF").toList()
assertEquals(KableScanFilter.Address("AA:BB:CC:DD:EE:FF"), scanner.lastFilter)
}
private class TestKableBleScanner(private val scanResults: Flow<KableScanResult>) :
KableBleScanner(BleLoggingConfig.Release) {
var lastFilter: KableScanFilter? = null
private set
override fun advertisements(filter: KableScanFilter): Flow<KableScanResult> {
lastFilter = filter
return scanResults
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.XModemManager
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshRouterImplTest {
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val tracerouteHandler = mock<TracerouteHandler>(MockMode.autofill)
private val neighborInfoHandler = mock<NeighborInfoHandler>(MockMode.autofill)
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
private val mqttManager = mock<MqttManager>(MockMode.autofill)
private val actionHandler = mock<MeshActionHandler>(MockMode.autofill)
private val xmodemManager = mock<XModemManager>(MockMode.autofill)
private val configHandler =
object : MeshConfigHandler {
override val localConfig = MutableStateFlow(LocalConfig())
override val moduleConfig = MutableStateFlow(LocalModuleConfig())
override fun handleDeviceConfig(config: org.meshtastic.proto.Config) = Unit
override fun handleModuleConfig(config: org.meshtastic.proto.ModuleConfig) = Unit
override fun handleChannel(channel: org.meshtastic.proto.Channel) = Unit
override fun handleDeviceUIConfig(config: org.meshtastic.proto.DeviceUIConfig) = Unit
}
private lateinit var dataHandlerLazy: TrackingLazy<MeshDataHandler>
private lateinit var configHandlerLazy: TrackingLazy<MeshConfigHandler>
private lateinit var tracerouteHandlerLazy: TrackingLazy<TracerouteHandler>
private lateinit var neighborInfoHandlerLazy: TrackingLazy<NeighborInfoHandler>
private lateinit var configFlowManagerLazy: TrackingLazy<MeshConfigFlowManager>
private lateinit var mqttManagerLazy: TrackingLazy<MqttManager>
private lateinit var actionHandlerLazy: TrackingLazy<MeshActionHandler>
private lateinit var xmodemManagerLazy: TrackingLazy<XModemManager>
private lateinit var router: MeshRouterImpl
@BeforeTest
fun setUp() {
dataHandlerLazy = TrackingLazy { dataHandler }
configHandlerLazy = TrackingLazy { configHandler }
tracerouteHandlerLazy = TrackingLazy { tracerouteHandler }
neighborInfoHandlerLazy = TrackingLazy { neighborInfoHandler }
configFlowManagerLazy = TrackingLazy { configFlowManager }
mqttManagerLazy = TrackingLazy { mqttManager }
actionHandlerLazy = TrackingLazy { actionHandler }
xmodemManagerLazy = TrackingLazy { xmodemManager }
router =
MeshRouterImpl(
dataHandlerLazy = dataHandlerLazy,
configHandlerLazy = configHandlerLazy,
tracerouteHandlerLazy = tracerouteHandlerLazy,
neighborInfoHandlerLazy = neighborInfoHandlerLazy,
configFlowManagerLazy = configFlowManagerLazy,
mqttManagerLazy = mqttManagerLazy,
actionHandlerLazy = actionHandlerLazy,
xmodemManagerLazy = xmodemManagerLazy,
)
}
@Test
fun `send message routing uses the action handler lazily`() {
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
assertAllHandlersUninitialized()
router.actionHandler.handleSend(packet, 12345)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verify { actionHandler.handleSend(packet, 12345) }
}
@Test
fun `request position routing uses the action handler lazily`() {
val position = Position(latitude = 37.7749, longitude = -122.4194, altitude = 10)
router.actionHandler.handleRequestPosition(destNum = 67890, position = position, myNodeNum = 12345)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verify { actionHandler.handleRequestPosition(67890, position, 12345) }
}
@Test
fun `traceroute routing uses the traceroute handler lazily`() {
assertAllHandlersUninitialized()
router.tracerouteHandler.recordStartTime(77)
assertTrue(tracerouteHandlerLazy.isInitialized())
assertFalse(actionHandlerLazy.isInitialized())
verify { tracerouteHandler.recordStartTime(77) }
}
@Test
fun `admin command routing uses the action handler lazily`() {
assertAllHandlersUninitialized()
router.actionHandler.handleGetRemoteConfig(id = 42, destNum = 67890, config = 7)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(configHandlerLazy.isInitialized())
verify { actionHandler.handleGetRemoteConfig(42, 67890, 7) }
}
@Test
fun `service actions are passed through unchanged to the action handler`() = runTest {
val action = ServiceAction.Favorite(Node(num = 67890))
router.actionHandler.onServiceAction(action)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verifySuspend { actionHandler.onServiceAction(action) }
}
private fun assertAllHandlersUninitialized() {
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(configHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
assertFalse(neighborInfoHandlerLazy.isInitialized())
assertFalse(configFlowManagerLazy.isInitialized())
assertFalse(mqttManagerLazy.isInitialized())
assertFalse(actionHandlerLazy.isInitialized())
assertFalse(xmodemManagerLazy.isInitialized())
}
private class TrackingLazy<T>(private val initializer: () -> T) : Lazy<T> {
private var cached: Any? = Uninitialized
override val value: T
get() {
if (cached === Uninitialized) {
cached = initializer()
}
@Suppress("UNCHECKED_CAST")
return cached as T
}
override fun isInitialized(): Boolean = cached !== Uninitialized
private object Uninitialized
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.robolectric.annotation.Config
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class DatabaseManagerWithDbRetryTest {
private val oldAddress = "AA:BB:CC:DD:EE:01"
private val newAddress = "AA:BB:CC:DD:EE:02"
private lateinit var manager: DatabaseManager
private lateinit var datastoreName: String
@BeforeTest
fun setUp() {
ContextServices.app = ApplicationProvider.getApplicationContext()
datastoreName = "db-manager-retry-${System.nanoTime()}"
manager =
DatabaseManager(
datastore = createDatabaseDataStore(datastoreName),
dispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.IO, default = Dispatchers.Default),
)
}
@AfterTest
fun tearDown() {
manager.close()
deleteDatabase(DatabaseConstants.DEFAULT_DB_NAME)
deleteDatabase(buildDbName(oldAddress))
deleteDatabase(buildDbName(newAddress))
ContextServices.app.preferencesDataStoreFile(datastoreName).delete()
}
@Test
fun `withDb retries against current database when previous pool closes during switch`() = runTest {
manager.switchActiveDatabase(oldAddress)
val oldDb = manager.currentDb.value
val started = CompletableDeferred<Unit>()
val continueFirstAttempt = CompletableDeferred<Unit>()
val visitedDbs = mutableListOf<MeshtasticDatabase>()
var attempts = 0
val result = async {
manager.withDb { db ->
visitedDbs += db
when (++attempts) {
1 -> {
started.complete(Unit)
continueFirstAttempt.await()
}
}
db.nodeInfoDao().getMyNodeInfo().first()?.myNodeNum
}
}
started.await()
manager.switchActiveDatabase(newAddress)
val newDb = manager.currentDb.value
newDb.nodeInfoDao().setMyNodeInfo(newMyNodeInfo)
oldDb.close()
continueFirstAttempt.complete(Unit)
assertEquals(newMyNodeInfo.myNodeNum, result.await())
assertEquals(2, attempts)
assertSame(oldDb, visitedDbs.first())
assertSame(newDb, visitedDbs.last())
}
private companion object {
val newMyNodeInfo =
MyNodeEntity(
myNodeNum = 42424242,
model = "TBEAM",
firmwareVersion = "2.5.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 300000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class QuickChatActionDaoTest : CommonQuickChatActionDaoTest()

View File

@@ -0,0 +1,199 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ConvertersTest {
private val converters = Converters()
@Test
fun `data packet string converter round trips`() {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "hello mesh".encodeToByteArray().toByteString(),
dataType = 1,
from = "!12345678",
id = 42,
status = MessageStatus.DELIVERED,
hopLimit = 3,
channel = 2,
wantAck = false,
rssi = -80,
)
val encoded = converters.dataToString(packet)
val decoded = converters.dataFromString(encoded)
assertEquals(packet, decoded)
}
@Test
fun `from radio converter round trips`() {
assertProtoRoundTrip(
expected = FromRadio(queueStatus = QueueStatus(res = 1, free = 2, mesh_packet_id = 3)),
toBytes = converters::fromRadioToBytes,
fromBytes = converters::bytesToFromRadio,
)
}
@Test
fun `user converter round trips`() {
assertProtoRoundTrip(
expected =
User(id = "!abcdef01", long_name = "Test User", short_name = "TU", hw_model = HardwareModel.TBEAM),
toBytes = converters::userToBytes,
fromBytes = converters::bytesToUser,
)
}
@Test
fun `position converter round trips`() {
assertProtoRoundTrip(
expected =
Position(latitude_i = 450000000, longitude_i = 900000000, altitude = 123, time = 456, sats_in_view = 7),
toBytes = converters::positionToBytes,
fromBytes = converters::bytesToPosition,
)
}
@Test
fun `telemetry converter round trips`() {
assertProtoRoundTrip(
expected =
Telemetry(
time = 1000,
device_metrics =
DeviceMetrics(
battery_level = 85,
voltage = 4.1f,
channel_utilization = 0.12f,
air_util_tx = 0.05f,
uptime_seconds = 123456,
),
),
toBytes = converters::telemetryToBytes,
fromBytes = converters::bytesToTelemetry,
)
}
@Test
fun `paxcount converter round trips`() {
assertProtoRoundTrip(
expected = Paxcount(wifi = 10, ble = 5, uptime = 1000),
toBytes = converters::paxCounterToBytes,
fromBytes = converters::bytesToPaxcounter,
)
}
@Test
fun `device metadata converter round trips`() {
assertProtoRoundTrip(
expected = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false),
toBytes = converters::metadataToBytes,
fromBytes = converters::bytesToMetadata,
)
}
@Test
fun `empty proto messages round trip to empty defaults`() {
assertEquals(FromRadio(), converters.bytesToFromRadio(converters.fromRadioToBytes(FromRadio())))
assertEquals(User(), converters.bytesToUser(converters.userToBytes(User())))
assertEquals(Position(), converters.bytesToPosition(converters.positionToBytes(Position())))
assertEquals(Telemetry(), converters.bytesToTelemetry(converters.telemetryToBytes(Telemetry())))
assertEquals(Paxcount(), converters.bytesToPaxcounter(converters.paxCounterToBytes(Paxcount())))
assertEquals(DeviceMetadata(), converters.bytesToMetadata(converters.metadataToBytes(DeviceMetadata())))
}
@Test
fun `empty byte arrays decode to empty proto messages`() {
val emptyBytes = byteArrayOf()
assertEquals(FromRadio(), converters.bytesToFromRadio(emptyBytes))
assertEquals(User(), converters.bytesToUser(emptyBytes))
assertEquals(Position(), converters.bytesToPosition(emptyBytes))
assertEquals(Telemetry(), converters.bytesToTelemetry(emptyBytes))
assertEquals(Paxcount(), converters.bytesToPaxcounter(emptyBytes))
assertEquals(DeviceMetadata(), converters.bytesToMetadata(emptyBytes))
}
@Test
fun `string list converter round trips and handles null`() {
val value = listOf("alpha", "beta")
val encoded = converters.toStringList(value)
assertNotNull(encoded)
assertEquals(value, converters.fromStringList(encoded))
assertNull(converters.toStringList(null))
assertNull(converters.fromStringList(null))
}
@Test
fun `byte string converter round trips and handles null`() {
val value = byteArrayOf(1, 2, 3, 4).toByteString()
val encoded = converters.byteStringToBytes(value)
assertNotNull(encoded)
assertEquals(value, converters.bytesToByteString(encoded))
assertNull(converters.byteStringToBytes(null))
assertNull(converters.bytesToByteString(null))
}
@Test
fun `empty byte arrays round trip as empty byte strings`() {
val emptyByteString = ByteString.EMPTY
val encoded = converters.byteStringToBytes(emptyByteString)
assertNotNull(encoded)
assertEquals(0, encoded.size)
assertEquals(emptyByteString, converters.bytesToByteString(byteArrayOf()))
}
@Test
fun `message status converter round trips and defaults unknown`() {
assertEquals(
MessageStatus.DELIVERED,
converters.intToMessageStatus(converters.messageStatusToInt(MessageStatus.DELIVERED)),
)
assertEquals(MessageStatus.UNKNOWN.ordinal, converters.messageStatusToInt(null))
assertEquals(MessageStatus.UNKNOWN, converters.intToMessageStatus(-1))
}
private fun <T> assertProtoRoundTrip(expected: T, toBytes: (T) -> ByteArray, fromBytes: (ByteArray) -> T) {
val encoded = toBytes(expected)
val decoded = fromBytes(encoded)
assertEquals(expected, decoded)
}
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.core.testing.setupTestContext
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
abstract class CommonQuickChatActionDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var dao: QuickChatActionDao
suspend fun createDb() {
setupTestContext()
database = getInMemoryDatabaseBuilder().build()
dao = database.quickChatActionDao()
}
@AfterTest
fun closeDb() {
database.close()
}
@Test
fun testInsertActionAndRetrieveIt() = runTest {
createDb()
val action = testAction(uuid = 1L, name = "Greeting", message = "Hello", position = 0)
dao.upsert(action)
assertEquals(listOf(action), dao.getAll().first())
}
@Test
fun testUpdateAction() = runTest {
createDb()
val action = testAction(uuid = 1L, name = "Greeting", message = "Hello", position = 0)
val updatedAction =
action.copy(name = "Updated Greeting", message = "Updated Hello", mode = QuickChatAction.Mode.Append)
dao.upsert(action)
dao.upsert(updatedAction)
assertEquals(listOf(updatedAction), dao.getAll().first())
}
@Test
fun testDeleteAction() = runTest {
createDb()
val first = testAction(uuid = 1L, name = "First", position = 0)
val second = testAction(uuid = 2L, name = "Second", position = 1)
val third = testAction(uuid = 3L, name = "Third", position = 2)
dao.upsert(first)
dao.upsert(second)
dao.upsert(third)
dao.delete(second)
val remaining = dao.getAll().first()
assertEquals(listOf(first, third.copy(position = 1)), remaining)
}
@Test
fun testDeleteAll() = runTest {
createDb()
dao.upsert(testAction(uuid = 1L, name = "First", position = 0))
dao.upsert(testAction(uuid = 2L, name = "Second", position = 1))
dao.deleteAll()
assertTrue(dao.getAll().first().isEmpty())
}
@Test
fun testReactiveFlowEmitsUpdatesOnInsertAndDelete() = runTest {
createDb()
val action = testAction(uuid = 1L, name = "Greeting", position = 0)
assertTrue(dao.getAll().first().isEmpty())
val inserted = async { dao.getAll().first { it == listOf(action) } }
dao.upsert(action)
assertEquals(listOf(action), inserted.await())
val deleted = async { dao.getAll().first { it.isEmpty() } }
dao.delete(action)
assertTrue(deleted.await().isEmpty())
}
@Test
fun testPositionOrdering() = runTest {
createDb()
val last = testAction(uuid = 1L, name = "Last", position = 2)
val first = testAction(uuid = 2L, name = "First", position = 0)
val middle = testAction(uuid = 3L, name = "Middle", position = 1)
dao.upsert(last)
dao.upsert(first)
dao.upsert(middle)
dao.updateActionPosition(last.uuid, position = 3)
val actions = dao.getAll().first()
assertEquals(listOf(first.uuid, middle.uuid, last.uuid), actions.map { it.uuid })
assertEquals(listOf(0, 1, 3), actions.map { it.position })
}
private fun testAction(
uuid: Long,
name: String,
message: String = "message-$uuid",
mode: QuickChatAction.Mode = QuickChatAction.Mode.Instant,
position: Int,
): QuickChatAction = QuickChatAction(uuid = uuid, name = name, message = message, mode = mode, position = position)
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.message_status_delivered
import org.meshtastic.core.resources.message_status_unknown
import org.meshtastic.core.resources.routing_error_no_route
import org.meshtastic.core.resources.routing_error_none
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DataPacketTest {
@Test
fun nodeNumToDefaultId_formatsHexWithPrefix() {
assertEquals("!1234abcd", DataPacket.nodeNumToDefaultId(0x1234ABCD))
assertEquals("!ffffffff", DataPacket.nodeNumToDefaultId(DataPacket.NODENUM_BROADCAST))
}
@Test
fun textConstructor_setsTextPayloadProperties() {
val packet = DataPacket(to = "!abcdef12", channel = 2, text = "hello mesh", replyId = 99)
assertEquals("hello mesh", packet.text)
assertNull(packet.alert)
assertEquals(PortNum.TEXT_MESSAGE_APP.value, packet.dataType)
assertEquals("!abcdef12", packet.to)
assertEquals(2, packet.channel)
assertEquals(99, packet.replyId)
assertEquals("hello mesh".encodeUtf8(), packet.bytes)
}
@Test
fun alertProperty_onlyReturnsForAlertPackets() {
val alertPacket = DataPacket(bytes = "wake up".encodeUtf8(), dataType = PortNum.ALERT_APP.value)
assertEquals("wake up", alertPacket.alert)
assertNull(alertPacket.text)
}
@Test
fun equalityAndCopy_preserveDataUntilModified() {
val packet =
DataPacket(
to = "!12345678",
from = "!87654321",
bytes = "payload".encodeUtf8(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
id = 7,
status = MessageStatus.ENROUTE,
hopLimit = 3,
hopStart = 5,
wantAck = true,
channel = 1,
snr = 4.5f,
rssi = -70,
replyId = 11,
relayNode = 42,
relays = 2,
viaMqtt = true,
transportMechanism = 9,
)
val identicalCopy = packet.copy()
val modifiedCopy = packet.copy(status = MessageStatus.DELIVERED, wantAck = false)
assertEquals(packet, identicalCopy)
assertEquals(2, packet.hopsAway)
assertEquals(MessageStatus.ENROUTE, packet.status)
assertNotEquals(packet, modifiedCopy)
assertEquals(MessageStatus.DELIVERED, modifiedCopy.status)
assertFalse(modifiedCopy.wantAck)
assertEquals(MessageStatus.ENROUTE, packet.status)
}
@Test
fun hopsAway_isUnknownForInvalidHopValues() {
assertEquals(-1, DataPacket(bytes = null, dataType = 0, hopStart = 0, hopLimit = 0).hopsAway)
assertEquals(-1, DataPacket(bytes = null, dataType = 0, hopStart = 2, hopLimit = 3).hopsAway)
}
}
class MessageTest {
@Test
fun messageConstruction_preservesProperties() {
val originalMessage =
Message(
uuid = 1L,
receivedTime = 10L,
node = Node.createFallback(0x12345678, "Node"),
text = "original",
fromLocal = true,
time = "10:00",
read = true,
status = MessageStatus.RECEIVED,
routingError = 0,
packetId = 101,
emojis = emptyList(),
snr = 1.5f,
rssi = -65,
hopsAway = 1,
replyId = null,
)
val reaction =
Reaction(
replyId = 101,
user = originalMessage.node.user,
emoji = "👍",
timestamp = 20L,
snr = 2.5f,
rssi = -55,
hopsAway = 2,
packetId = 202,
status = MessageStatus.DELIVERED,
relayNode = 77,
relays = 1,
to = "!12345678",
channel = 3,
)
val message =
Message(
uuid = 2L,
receivedTime = 30L,
node = originalMessage.node,
text = "reply",
fromLocal = false,
time = "10:01",
read = false,
status = MessageStatus.ENROUTE,
routingError = 0,
packetId = 202,
emojis = listOf(reaction),
snr = 3.5f,
rssi = -75,
hopsAway = 3,
replyId = 101,
originalMessage = originalMessage,
viaMqtt = true,
relayNode = 88,
relays = 2,
filtered = true,
transportMechanism = 4,
)
assertEquals(2L, message.uuid)
assertEquals(30L, message.receivedTime)
assertEquals(originalMessage.node, message.node)
assertEquals("reply", message.text)
assertFalse(message.fromLocal)
assertFalse(message.read)
assertEquals(MessageStatus.ENROUTE, message.status)
assertEquals(202, message.packetId)
assertEquals(1, message.emojis.size)
assertEquals(reaction, message.emojis.single())
assertEquals(101, message.replyId)
assertEquals(originalMessage, message.originalMessage)
assertEquals(88, message.relayNode)
assertEquals(2, message.relays)
assertEquals(4, message.transportMechanism)
assertTrue(message.viaMqtt)
assertTrue(message.filtered)
}
@Test
fun getStatusStringRes_returnsDeliveryResources() {
val message =
Message(
uuid = 1L,
receivedTime = 0L,
node = Node.createFallback(1, "Node"),
text = "hello",
fromLocal = true,
time = "now",
read = false,
status = MessageStatus.DELIVERED,
routingError = 0,
packetId = 1,
emojis = emptyList(),
snr = 0f,
rssi = 0,
hopsAway = 0,
replyId = null,
)
val (title, text) = message.getStatusStringRes()
assertEquals(Res.string.message_delivery_status, title)
assertEquals(Res.string.message_status_delivered, text)
}
@Test
fun getStatusStringRes_returnsErrorResourcesForRoutingFailures() {
val message =
Message(
uuid = 1L,
receivedTime = 0L,
node = Node.createFallback(1, "Node"),
text = "hello",
fromLocal = true,
time = "now",
read = false,
status = MessageStatus.ERROR,
routingError = Routing.Error.NO_ROUTE.value,
packetId = 1,
emojis = emptyList(),
snr = 0f,
rssi = 0,
hopsAway = 0,
replyId = null,
)
val (title, text) = message.getStatusStringRes()
assertEquals(Res.string.error, title)
assertEquals(Res.string.routing_error_no_route, text)
}
@Test
fun getStatusStringRes_returnsUnknownForMissingStatus() {
val message =
Message(
uuid = 1L,
receivedTime = 0L,
node = Node.createFallback(1, "Node"),
text = "hello",
fromLocal = true,
time = "now",
read = false,
status = null,
routingError = 0,
packetId = 1,
emojis = emptyList(),
snr = 0f,
rssi = 0,
hopsAway = 0,
replyId = null,
)
val (title, text) = message.getStatusStringRes()
assertEquals(Res.string.message_delivery_status, title)
assertEquals(Res.string.message_status_unknown, text)
}
@Test
fun getStringResFrom_mapsUnknownValuesToUnrecognized() {
assertEquals(Res.string.routing_error_none, getStringResFrom(Routing.Error.NONE.value))
assertEquals(Res.string.unrecognized, getStringResFrom(Int.MAX_VALUE))
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class NodeTest {
@Test
fun isOnline_usesStrictThresholdBoundary() {
val threshold = onlineTimeThreshold()
assertFalse(Node(num = 1, lastHeard = threshold).isOnline)
assertTrue(Node(num = 1, lastHeard = threshold + 1).isOnline)
}
@Test
fun distance_returnsMetersForKnownCoordinates() {
val a = nodeWithPosition(num = 1, latitudeI = 450000000, longitudeI = -930000000)
val b = nodeWithPosition(num = 2, latitudeI = 450000000, longitudeI = -920000000)
val distance = a.distance(b)
assertNotNull(distance)
assertTrue(distance in 78000..79000, "Distance was $distance")
}
@Test
fun bearing_returnsCardinalDirections() {
val northOrigin = nodeWithPosition(num = 1, latitudeI = 100000000, longitudeI = 100000000)
val northTarget = nodeWithPosition(num = 2, latitudeI = 200000000, longitudeI = 100000000)
val southTarget = nodeWithPosition(num = 3, latitudeI = -100000000, longitudeI = 100000000)
val eastOrigin = nodeWithPosition(num = 4, latitudeI = 1, longitudeI = 200000000)
val eastTarget = nodeWithPosition(num = 5, latitudeI = 1, longitudeI = 300000000)
val westTarget = nodeWithPosition(num = 6, latitudeI = 1, longitudeI = 100000000)
assertEquals(0, northOrigin.bearing(northTarget))
assertEquals(180, northOrigin.bearing(southTarget))
assertTrue((eastOrigin.bearing(eastTarget) ?: -1) in 89..90)
assertTrue((eastOrigin.bearing(westTarget) ?: -1) in 269..270)
}
@Test
fun colors_returnsForegroundAndBackgroundValues() {
val colors = Node(num = 0x123456).colors
assertNotNull(colors.first)
assertNotNull(colors.second)
}
@Test
fun createFallback_createsUnknownUserWithDerivedNames() {
val node = Node.createFallback(nodeNum = 0x12345678, fallbackNamePrefix = "Unknown")
assertEquals(0x12345678, node.num)
assertEquals("!12345678", node.user.id)
assertEquals("Unknown 5678", node.user.long_name)
assertEquals("5678", node.user.short_name)
assertEquals(HardwareModel.UNSET, node.user.hw_model)
assertTrue(node.isUnknownUser)
}
@Test
fun getRelayNode_filtersCandidatesAndChoosesFewestHops() {
val chosen = Node(num = 0x000001AA, lastHeard = 100, hopsAway = 2)
val farther = Node(num = 0x000002AA, lastHeard = 100, hopsAway = 5)
val unheard = Node(num = 0x000003AA, lastHeard = 0, hopsAway = 1)
val ourNode = Node(num = 0x000004AA, lastHeard = 100, hopsAway = 0)
val otherSuffix = Node(num = 0x000005BB, lastHeard = 100, hopsAway = 1)
val relayNode =
Node.getRelayNode(
relayNodeId = 0x0000FFAA.toInt(),
nodes = listOf(chosen, farther, unheard, ourNode, otherSuffix),
ourNodeNum = ourNode.num,
)
assertEquals(chosen, relayNode)
}
@Test
fun isUnknownUser_falseWhenHardwareModelIsKnown() {
val node = Node(num = 1, user = User(hw_model = HardwareModel.TLORA_V2))
assertFalse(node.isUnknownUser)
}
@Test
fun validPosition_returnsPositionOnlyForValidCoordinates() {
val validPosition = Position(latitude_i = 377749000, longitude_i = -1224194000)
val validNode = Node(num = 1, position = validPosition)
val zeroNode = Node(num = 2, position = Position(latitude_i = 0, longitude_i = 0))
val outOfRangeNode = Node(num = 3, position = Position(latitude_i = 910000000, longitude_i = -1224194000))
assertEquals(validPosition, validNode.validPosition)
assertEquals(null, zeroNode.validPosition)
assertEquals(null, outOfRangeNode.validPosition)
}
@Test
fun hasPKC_usesUserPublicKeyWhenNodeKeyIsMissing() {
val key = ByteArray(32) { (it + 1).toByte() }.toByteString()
val node = Node(num = 1, user = User(public_key = key))
assertTrue(node.hasPKC)
assertFalse(node.mismatchKey)
}
@Test
fun mismatchKey_trueForErrorByteString() {
val node = Node(num = 1, publicKey = Node.ERROR_BYTE_STRING)
assertTrue(node.hasPKC)
assertTrue(node.mismatchKey)
}
private fun nodeWithPosition(num: Int, latitudeI: Int, longitudeI: Int): Node =
Node(num = num, position = Position(latitude_i = latitudeI, longitude_i = longitudeI))
}

View File

@@ -0,0 +1,286 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.meshtastic.core.model.DeviceMetrics as DomainDeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics as DomainEnvironmentMetrics
import org.meshtastic.core.model.Position as DomainPosition
class MeshDataMapperTest {
private val mapper = MeshDataMapper(TestNodeIdLookup())
@Test
fun toDataPacket_returnsNullWhenPacketHasNoDecodedData() {
assertNull(mapper.toDataPacket(MeshPacket(from = 0x12345678)))
}
@Test
fun toDataPacket_mapsMeshPacketFields() {
val payload = "mesh payload".encodeUtf8()
val packet =
MeshPacket(
from = 0x12345678,
to = 0x90ABCDEF.toInt(),
rx_time = 123,
id = 456,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = payload, reply_id = 789, emoji = 321),
hop_limit = 3,
channel = 4,
want_ack = true,
hop_start = 5,
rx_snr = 6.5f,
rx_rssi = -70,
relay_node = 77,
via_mqtt = true,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_MQTT,
)
val mapped = mapper.toDataPacket(packet)
assertNotNull(mapped)
assertEquals("!12345678", mapped.from)
assertEquals("!90abcdef", mapped.to)
assertEquals(123_000L, mapped.time)
assertEquals(456, mapped.id)
assertEquals(PortNum.TEXT_MESSAGE_APP.value, mapped.dataType)
assertEquals(payload, mapped.bytes)
assertEquals(3, mapped.hopLimit)
assertEquals(4, mapped.channel)
assertTrue(mapped.wantAck)
assertEquals(5, mapped.hopStart)
assertEquals(6.5f, mapped.snr)
assertEquals(-70, mapped.rssi)
assertEquals(789, mapped.replyId)
assertEquals(77, mapped.relayNode)
assertTrue(mapped.viaMqtt)
assertEquals(321, mapped.emoji)
assertEquals(MeshPacket.TransportMechanism.TRANSPORT_MQTT.value, mapped.transportMechanism)
}
@Test
fun toDataPacket_usesPkcChannelWhenPacketIsPkiEncrypted() {
val packet =
MeshPacket(
from = 1,
to = 2,
channel = 2,
pki_encrypted = true,
decoded = Data(portnum = PortNum.PRIVATE_APP),
)
val mapped = mapper.toDataPacket(packet)
assertNotNull(mapped)
assertEquals(DataPacket.PKC_CHANNEL_INDEX, mapped.channel)
}
@Test
fun meshUser_mapsProtoFields() {
val proto =
User(
id = "!cafebabe",
long_name = "Meshtastic User",
short_name = "MU",
hw_model = HardwareModel.TLORA_V2,
is_licensed = true,
role = Config.DeviceConfig.Role.ROUTER,
)
val user = MeshUser(proto)
assertEquals("!cafebabe", user.id)
assertEquals("Meshtastic User", user.longName)
assertEquals("MU", user.shortName)
assertEquals(HardwareModel.TLORA_V2, user.hwModel)
assertTrue(user.isLicensed)
assertEquals(Config.DeviceConfig.Role.ROUTER.value, user.role)
}
@Test
fun meshUser_defaultsEmptyFieldsFromEmptyProto() {
val user = MeshUser(User())
assertEquals("", user.id)
assertEquals("", user.longName)
assertEquals("", user.shortName)
assertEquals(HardwareModel.UNSET, user.hwModel)
assertFalse(user.isLicensed)
assertEquals(0, user.role)
}
@Test
fun position_mapsScaledCoordinatesAndProvidedTime() {
val proto =
Position(
latitude_i = 377749000,
longitude_i = -1224194000,
altitude = 15,
time = 456,
sats_in_view = 9,
ground_speed = 12,
ground_track = 180,
precision_bits = 7,
)
val position = DomainPosition(proto, defaultTime = 123)
assertEquals(37.7749, position.latitude, 1e-6)
assertEquals(-122.4194, position.longitude, 1e-6)
assertEquals(15, position.altitude)
assertEquals(456, position.time)
assertEquals(9, position.satellitesInView)
assertEquals(12, position.groundSpeed)
assertEquals(180, position.groundTrack)
assertEquals(7, position.precisionBits)
}
@Test
fun position_usesDefaultTimeAndZeroValuesForUnsetProtoFields() {
val position = DomainPosition(Position(), defaultTime = 789)
assertEquals(0.0, position.latitude)
assertEquals(0.0, position.longitude)
assertEquals(0, position.altitude)
assertEquals(789, position.time)
assertEquals(0, position.satellitesInView)
assertEquals(0, position.groundSpeed)
assertEquals(0, position.groundTrack)
assertEquals(0, position.precisionBits)
}
@Test
fun deviceMetrics_mapsProtoFields() {
val proto =
DeviceMetrics(
battery_level = 87,
voltage = 4.12f,
channel_utilization = 32.5f,
air_util_tx = 7.75f,
uptime_seconds = 3600,
)
val metrics = DomainDeviceMetrics(proto, telemetryTime = 123)
assertEquals(123, metrics.time)
assertEquals(87, metrics.batteryLevel)
assertEquals(4.12f, metrics.voltage)
assertEquals(32.5f, metrics.channelUtilization)
assertEquals(7.75f, metrics.airUtilTx)
assertEquals(3600, metrics.uptimeSeconds)
}
@Test
fun deviceMetrics_defaultsUnsetFieldsToZero() {
val metrics = DomainDeviceMetrics(DeviceMetrics(), telemetryTime = 222)
assertEquals(222, metrics.time)
assertEquals(0, metrics.batteryLevel)
assertEquals(0f, metrics.voltage)
assertEquals(0f, metrics.channelUtilization)
assertEquals(0f, metrics.airUtilTx)
assertEquals(0, metrics.uptimeSeconds)
}
@Test
fun environmentMetrics_mapsTelemetryFields() {
val proto =
EnvironmentMetrics(
temperature = 24.5f,
relative_humidity = 55.5f,
soil_temperature = 18.25f,
soil_moisture = 44,
barometric_pressure = 1013.2f,
gas_resistance = 10.5f,
voltage = 3.7f,
current = 0.8f,
iaq = 42,
lux = 321.5f,
uv_lux = 4.2f,
)
val metrics = DomainEnvironmentMetrics.fromTelemetryProto(proto, time = 999)
assertEquals(999, metrics.time)
assertEquals(24.5f, metrics.temperature)
assertEquals(55.5f, metrics.relativeHumidity)
assertEquals(18.25f, metrics.soilTemperature)
assertEquals(44, metrics.soilMoisture)
assertEquals(1013.2f, metrics.barometricPressure)
assertEquals(10.5f, metrics.gasResistance)
assertEquals(3.7f, metrics.voltage)
assertEquals(0.8f, metrics.current)
assertEquals(42, metrics.iaq)
assertEquals(321.5f, metrics.lux)
assertEquals(4.2f, metrics.uvLux)
}
@Test
fun environmentMetrics_filtersSentinelAndInvalidValues() {
val proto =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = 0.0f,
soil_temperature = Float.NaN,
soil_moisture = Int.MIN_VALUE,
barometric_pressure = Float.NaN,
gas_resistance = Float.NaN,
voltage = Float.NaN,
current = Float.NaN,
iaq = Int.MIN_VALUE,
lux = Float.NaN,
uv_lux = Float.NaN,
)
val metrics = DomainEnvironmentMetrics.fromTelemetryProto(proto, time = 111)
assertEquals(111, metrics.time)
assertNull(metrics.temperature)
assertNull(metrics.relativeHumidity)
assertNull(metrics.soilTemperature)
assertNull(metrics.soilMoisture)
assertNull(metrics.barometricPressure)
assertNull(metrics.gasResistance)
assertNull(metrics.voltage)
assertNull(metrics.current)
assertNull(metrics.iaq)
assertNull(metrics.lux)
assertNull(metrics.uvLux)
}
private class TestNodeIdLookup : NodeIdLookup {
override fun toNodeID(nodeNum: Int): String = DataPacket.nodeNumToDefaultId(nodeNum)
}
}

View File

@@ -51,6 +51,7 @@ import org.meshtastic.mqtt.MqttLogLevel
import org.meshtastic.mqtt.MqttMessage
import org.meshtastic.mqtt.QoS
import org.meshtastic.mqtt.packet.Subscription
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.MqttClientProxyMessage
import kotlin.concurrent.Volatile
@@ -62,18 +63,33 @@ class MQTTRepositoryImpl(
dispatchers: CoroutineDispatchers,
) : MQTTRepository {
internal constructor(
radioConfigRepository: RadioConfigRepository,
nodeRepository: NodeRepository,
buildConfigProvider: org.meshtastic.core.common.BuildConfigProvider,
dispatchers: CoroutineDispatchers,
mqttClientFactory: (MqttClientSetup) -> MqttClientSession,
) : this(
radioConfigRepository = radioConfigRepository,
nodeRepository = nodeRepository,
buildConfigProvider = buildConfigProvider,
dispatchers = dispatchers,
) {
this.mqttClientFactory = mqttClientFactory
}
companion object {
private const val DEFAULT_TOPIC_ROOT = "msh"
private const val DEFAULT_TOPIC_LEVEL = "/2/e/"
private const val JSON_TOPIC_LEVEL = "/2/json/"
private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org"
private const val KEEPALIVE_SECONDS = 30
private const val INITIAL_RECONNECT_DELAY_MS = 1000L
private const val MAX_RECONNECT_DELAY_MS = 30_000L
private const val RECONNECT_BACKOFF_MULTIPLIER = 2
}
@Volatile private var client: MqttClient? = null
@Volatile private var client: MqttClientSession? = null
private var mqttClientFactory: (MqttClientSetup) -> MqttClientSession = ::defaultMqttClientFactory
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected.Idle)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@@ -106,17 +122,13 @@ class MQTTRepositoryImpl(
val endpoint = resolveEndpoint(rawAddress, effectiveTlsEnabled(rawAddress, mqttConfig?.tls_enabled == true))
val newClient =
MqttClient(ownerId) {
keepAliveSeconds = KEEPALIVE_SECONDS
autoReconnect = true
username = mqttConfig?.username
mqttConfig?.password?.let { password(it) }
logger = KermitMqttLogger()
// WARN for production: the library emits endpoint addresses and topic strings at
// INFO level. WARN messages (reconnect, timeout, retry) contain no PII and are
// exactly the signals needed for production diagnostics.
logLevel = if (buildConfigProvider.isDebug) MqttLogLevel.DEBUG else MqttLogLevel.WARN
}
mqttClientFactory(
MqttClientSetup(
ownerId = ownerId,
mqttConfig = mqttConfig,
logLevel = if (buildConfigProvider.isDebug) MqttLogLevel.DEBUG else MqttLogLevel.WARN,
),
)
client = newClient
val subscriptions: List<Subscription> = buildList {
@@ -148,25 +160,7 @@ class MQTTRepositoryImpl(
// Forward the client's connection state to the repo-level StateFlow for UI observation.
// Also emit structured log messages on transitions so reconnect attempt counts and
// disconnect reason codes are visible in Crashlytics/Datadog without any PII.
launch {
newClient.connectionState.collect { state ->
_connectionState.value = state
when (state) {
ConnectionState.Connecting -> Logger.i { "MQTT connecting" }
ConnectionState.Connected -> Logger.i { "MQTT connected" }
is ConnectionState.Reconnecting -> {
val errorDetail = state.lastError?.message?.let { ": $it" } ?: ""
Logger.w { "MQTT reconnecting (attempt ${state.attempt}$errorDetail)" }
}
is ConnectionState.Disconnected -> {
state.reason?.let { Logger.w { "MQTT disconnected: ${it.message}" } }
}
}
}
}
launch { newClient.connectionState.collect { state -> updateConnectionState(state) } }
// Retry the initial connect with exponential backoff. Once established,
// autoReconnect handles subsequent drops and re-subscribes internally.
@@ -206,6 +200,24 @@ class MQTTRepositoryImpl(
awaitClose { disconnect() }
}
internal fun updateConnectionState(state: ConnectionState) {
_connectionState.value = state
when (state) {
ConnectionState.Connecting -> Logger.i { "MQTT connecting" }
ConnectionState.Connected -> Logger.i { "MQTT connected" }
is ConnectionState.Reconnecting -> {
val errorDetail = state.lastError?.message?.let { ": $it" } ?: ""
Logger.w { "MQTT reconnecting (attempt ${state.attempt}$errorDetail)" }
}
is ConnectionState.Disconnected -> {
state.reason?.let { Logger.w { "MQTT disconnected: ${it.message}" } }
}
}
}
@OptIn(ExperimentalSerializationApi::class)
private fun ProducerScope<MqttClientProxyMessage>.processMessage(msg: MqttMessage) {
val topic = msg.topic
@@ -261,6 +273,61 @@ class MQTTRepositoryImpl(
}
}
internal data class MqttClientSetup(
val ownerId: String,
val mqttConfig: ModuleConfig.MQTTConfig?,
val logLevel: MqttLogLevel,
)
internal interface MqttClientSession {
val messages: Flow<MqttMessage>
val connectionState: StateFlow<ConnectionState>
suspend fun connect(endpoint: MqttEndpoint)
suspend fun subscribe(subscriptions: List<Subscription>)
suspend fun publish(message: MqttMessage)
suspend fun close()
}
private class DefaultMqttClientSession(private val delegate: MqttClient) : MqttClientSession {
override val messages: Flow<MqttMessage> = delegate.messages
override val connectionState: StateFlow<ConnectionState> = delegate.connectionState
override suspend fun connect(endpoint: MqttEndpoint) {
delegate.connect(endpoint)
}
override suspend fun subscribe(subscriptions: List<Subscription>) {
delegate.subscribe(subscriptions)
}
override suspend fun publish(message: MqttMessage) {
delegate.publish(message)
}
override suspend fun close() {
delegate.close()
}
}
private fun defaultMqttClientFactory(setup: MqttClientSetup): MqttClientSession = DefaultMqttClientSession(
MqttClient(setup.ownerId) {
keepAliveSeconds = MQTT_KEEPALIVE_SECONDS
autoReconnect = true
username = setup.mqttConfig?.username
setup.mqttConfig?.password?.let { password(it) }
logger = KermitMqttLogger()
// WARN for production: the library emits endpoint addresses and topic strings at
// INFO level. WARN messages (reconnect, timeout, retry) contain no PII and are
// exactly the signals needed for production diagnostics.
logLevel = setup.logLevel
},
)
private const val MQTT_KEEPALIVE_SECONDS = 30
private const val MQTT_PORT_PLAIN = 1883
private const val MQTT_PORT_TLS = 8883

View File

@@ -16,16 +16,61 @@
*/
package org.meshtastic.core.network.repository
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MqttJsonPayload
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.mqtt.ConnectionState
import org.meshtastic.mqtt.MqttEndpoint
import org.meshtastic.mqtt.MqttException
import org.meshtastic.mqtt.MqttLogLevel
import org.meshtastic.mqtt.MqttMessage
import org.meshtastic.mqtt.QoS
import org.meshtastic.mqtt.ReasonCode
import org.meshtastic.mqtt.packet.Subscription
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class MQTTRepositoryImplTest {
private val buildConfigProvider: BuildConfigProvider = mock(MockMode.autofill)
@BeforeTest
fun setUp() {
every { buildConfigProvider.isDebug } returns true
}
// region resolveEndpoint — every behavioral branch of address parsing.
@Test
@@ -60,8 +105,6 @@ class MQTTRepositoryImplTest {
@Test
fun `address with ws scheme is parsed as-is and tls flag is ignored`() {
// tlsEnabled is intentionally true here — when the user supplies a full URL we
// must honor whatever scheme they provided, not silently upgrade it.
val endpoint = resolveEndpoint(rawAddress = "ws://broker.example.com:8080/custom-path", tlsEnabled = true)
val ws = assertIs<MqttEndpoint.WebSocket>(endpoint)
@@ -166,6 +209,180 @@ class MQTTRepositoryImplTest {
// endregion
@Test
fun `topic patterns are built from enabled channels with json topics and PKI`() = runTest {
val radioConfigRepository =
FakeRadioConfigRepository().apply {
setChannelSet(
ChannelSet(
settings =
listOf(
ChannelSettings(
name = "alpha",
downlink_enabled = true,
psk = byteArrayOf(1).toByteString(),
),
ChannelSettings(
name = "beta",
downlink_enabled = false,
psk = byteArrayOf(2).toByteString(),
),
ChannelSettings(
name = "gamma",
downlink_enabled = true,
psk = byteArrayOf(3).toByteString(),
),
),
),
)
setLocalModuleConfigDirect(
LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(root = "custom", json_enabled = true)),
)
}
val harness = createHarness(radioConfigRepository = radioConfigRepository)
val collector = startProxyCollection(harness.repository)
runCurrent()
val subscriptions = harness.client.subscribeCalls.single()
assertEquals(
listOf(
"custom/2/e/alpha/+",
"custom/2/json/alpha/+",
"custom/2/e/gamma/+",
"custom/2/json/gamma/+",
"custom/2/e/PKI/+",
),
subscriptions.map { it.topicFilter },
)
assertTrue(subscriptions.all { it.maxQos == QoS.AT_LEAST_ONCE && it.noLocal })
assertEquals("MeshtasticAndroidMqttProxy-!12345678", harness.setups.single().ownerId)
assertEquals(MqttLogLevel.DEBUG, harness.setups.single().logLevel)
collector.cancelAndJoin()
runCurrent()
assertEquals(1, harness.client.closeCalls)
}
@Test
fun `json mqtt messages are decoded into text proxy messages`() = runTest {
val harness =
createHarness(
radioConfigRepository =
FakeRadioConfigRepository().apply {
setLocalModuleConfigDirect(
LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(json_enabled = true)),
)
},
)
val jsonPayload = """{"type":"text","from":1,"to":2,"payload":"hello","hop_limit":3,"id":4,"time":5}"""
val nextMessage = backgroundScope.async { harness.repository.proxyMessageFlow.first() }
runCurrent()
harness.client.emitMessage(
MqttMessage(topic = "msh/2/json/alpha/node", payload = jsonPayload.encodeToByteArray(), retain = true),
)
val proxyMessage = nextMessage.await()
assertEquals("msh/2/json/alpha/node", proxyMessage.topic)
assertEquals(jsonPayload, proxyMessage.text)
assertEquals(true, proxyMessage.retained)
assertNull(proxyMessage.data_)
}
@Test
fun `protobuf mqtt messages are decoded into binary proxy messages`() = runTest {
val harness = createHarness()
val payload = byteArrayOf(0x01, 0x23, 0x45)
val nextMessage = backgroundScope.async { harness.repository.proxyMessageFlow.first() }
runCurrent()
harness.client.emitMessage(MqttMessage(topic = "msh/2/e/alpha/node", payload = payload, retain = false))
val proxyMessage = nextMessage.await()
assertEquals("msh/2/e/alpha/node", proxyMessage.topic)
assertContentEquals(payload, proxyMessage.data_?.toByteArray())
assertEquals(false, proxyMessage.retained)
assertNull(proxyMessage.text)
}
@Test
fun `connect retries after a transient failure and succeeds when the network recovers`() = runTest {
val harness = createHarness()
harness.client.failConnectWith(MqttException.ConnectionLost(ReasonCode.UNSPECIFIED_ERROR, "offline"))
val collector = startProxyCollection(harness.repository)
runCurrent()
assertEquals(1, harness.client.connectCalls.size)
assertEquals(0, harness.client.subscribeCalls.size)
advanceTimeBy(999)
runCurrent()
assertEquals(1, harness.client.connectCalls.size)
advanceTimeBy(1)
runCurrent()
assertEquals(2, harness.client.connectCalls.size)
assertEquals(1, harness.client.subscribeCalls.size)
collector.cancelAndJoin()
runCurrent()
}
@Test
fun `subscription failures trigger reconnect retry`() = runTest {
val harness = createHarness()
harness.client.failSubscribeWith(MqttException.ConnectionLost(ReasonCode.UNSPECIFIED_ERROR, "suback timeout"))
val collector = startProxyCollection(harness.repository)
runCurrent()
assertEquals(1, harness.client.connectCalls.size)
assertEquals(1, harness.client.subscribeCalls.size)
advanceTimeBy(1_000)
runCurrent()
assertEquals(2, harness.client.connectCalls.size)
assertEquals(2, harness.client.subscribeCalls.size)
collector.cancelAndJoin()
runCurrent()
}
@Test
fun `connection state flow reflects repository state updates`() {
val repository =
MQTTRepositoryImpl(
radioConfigRepository = FakeRadioConfigRepository(),
nodeRepository = FakeNodeRepository().apply { setMyId("!12345678") },
buildConfigProvider = buildConfigProvider,
dispatchers =
CoroutineDispatchers(
io = Dispatchers.Default,
main = Dispatchers.Default,
default = Dispatchers.Default,
),
mqttClientFactory = { FakeMqttClientSession() },
)
val disconnectError = MqttException.ConnectionLost(ReasonCode.UNSPECIFIED_ERROR, "link lost")
assertEquals(ConnectionState.Disconnected.Idle, repository.connectionState.value)
repository.updateConnectionState(ConnectionState.Connecting)
assertEquals(ConnectionState.Connecting, repository.connectionState.value)
repository.updateConnectionState(ConnectionState.Connected)
assertEquals(ConnectionState.Connected, repository.connectionState.value)
repository.updateConnectionState(ConnectionState.Reconnecting(attempt = 2, lastError = disconnectError))
val reconnecting = assertIs<ConnectionState.Reconnecting>(repository.connectionState.value)
assertEquals(2, reconnecting.attempt)
assertEquals("link lost", reconnecting.lastError?.message)
repository.updateConnectionState(ConnectionState.Disconnected(reason = disconnectError))
val disconnected = assertIs<ConnectionState.Disconnected>(repository.connectionState.value)
assertEquals("link lost", disconnected.reason?.message)
}
// region MqttJsonPayload — keep the existing JSON contract tests.
@Test
@@ -205,4 +422,94 @@ class MQTTRepositoryImplTest {
}
// endregion
private fun TestScope.createHarness(
radioConfigRepository: FakeRadioConfigRepository = defaultRadioConfigRepository(),
nodeRepository: FakeNodeRepository = FakeNodeRepository().apply { setMyId("!12345678") },
client: FakeMqttClientSession = FakeMqttClientSession(),
): RepositoryHarness {
val dispatcher = kotlinx.coroutines.test.StandardTestDispatcher(testScheduler)
val setups = mutableListOf<MqttClientSetup>()
val repository =
MQTTRepositoryImpl(
radioConfigRepository = radioConfigRepository,
nodeRepository = nodeRepository,
buildConfigProvider = buildConfigProvider,
dispatchers = CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher),
mqttClientFactory = { setup ->
setups += setup
client
},
)
return RepositoryHarness(repository = repository, client = client, setups = setups)
}
private fun TestScope.startProxyCollection(repository: MQTTRepositoryImpl): Job =
backgroundScope.launch { repository.proxyMessageFlow.collect {} }
private fun defaultRadioConfigRepository(): FakeRadioConfigRepository = FakeRadioConfigRepository().apply {
setChannelSet(
ChannelSet(
settings =
listOf(
ChannelSettings(
name = "alpha",
downlink_enabled = true,
psk = byteArrayOf(1).toByteString(),
),
),
),
)
}
private data class RepositoryHarness(
val repository: MQTTRepositoryImpl,
val client: FakeMqttClientSession,
val setups: List<MqttClientSetup>,
)
private class FakeMqttClientSession : MqttClientSession {
private val mutableMessages = MutableSharedFlow<MqttMessage>(extraBufferCapacity = 8)
override val messages: Flow<MqttMessage> = mutableMessages
override val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected.Idle)
val connectCalls = mutableListOf<MqttEndpoint>()
val subscribeCalls = mutableListOf<List<Subscription>>()
var closeCalls = 0
private set
private val connectFailures = ArrayDeque<Throwable>()
private val subscribeFailures = ArrayDeque<Throwable>()
override suspend fun connect(endpoint: MqttEndpoint) {
connectCalls += endpoint
if (connectFailures.isNotEmpty()) throw connectFailures.removeFirst()
}
override suspend fun subscribe(subscriptions: List<Subscription>) {
subscribeCalls += subscriptions
if (subscribeFailures.isNotEmpty()) throw subscribeFailures.removeFirst()
}
override suspend fun publish(message: MqttMessage) = Unit
override suspend fun close() {
closeCalls += 1
}
fun failConnectWith(throwable: Throwable) {
connectFailures.addLast(throwable)
}
fun failSubscribeWith(throwable: Throwable) {
subscribeFailures.addLast(throwable)
}
suspend fun emitMessage(message: MqttMessage) {
mutableMessages.emit(message)
}
fun emitState(state: ConnectionState) {
connectionState.value = state
}
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.transport
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.meshtastic.proto.ToRadio
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class HeartbeatSenderTest {
@Test
fun `sendHeartbeat encodes a heartbeat and runs afterHeartbeat after sending`() = runTest {
val sentPackets = mutableListOf<ByteArray>()
var afterHeartbeatCalls = 0
val sender =
HeartbeatSender(
sendToRadio = { sentPackets.add(it) },
afterHeartbeat = {
afterHeartbeatCalls++
assertEquals(1, sentPackets.size)
},
)
sender.sendHeartbeat()
assertEquals(1, sentPackets.size)
assertEquals(1, afterHeartbeatCalls)
val message = ToRadio.ADAPTER.decode(sentPackets.single())
val heartbeat = assertNotNull(message.heartbeat)
assertEquals(0, heartbeat.nonce)
assertNull(message.packet)
}
@Test
fun `heartbeat loop emits at the configured interval`() = runTest {
val sentPackets = mutableListOf<ByteArray>()
val sender = HeartbeatSender(sendToRadio = { sentPackets.add(it) })
val interval = 5.seconds
val job = launchFiniteHeartbeatLoop(sender = sender, interval = interval, repeatCount = 3)
runCurrent()
assertHeartbeats(sentPackets, 0)
advanceTimeBy(interval.inWholeMilliseconds)
runCurrent()
assertHeartbeats(sentPackets, 0, 1)
advanceTimeBy(interval.inWholeMilliseconds)
runCurrent()
assertHeartbeats(sentPackets, 0, 1, 2)
job.cancel()
}
@Test
fun `cancelling a heartbeat loop stops additional heartbeats`() = runTest {
val sentPackets = mutableListOf<ByteArray>()
val sender = HeartbeatSender(sendToRadio = { sentPackets.add(it) })
val interval = 5.seconds
val job = launchRepeatingHeartbeatLoop(sender = sender, interval = interval)
runCurrent()
advanceTimeBy(interval.inWholeMilliseconds)
runCurrent()
assertHeartbeats(sentPackets, 0, 1)
job.cancel()
advanceTimeBy(interval.inWholeMilliseconds * 5)
runCurrent()
assertHeartbeats(sentPackets, 0, 1)
}
@Test
fun `zero interval sends all scheduled heartbeats without advancing time`() = runTest {
val sentPackets = mutableListOf<ByteArray>()
val sender = HeartbeatSender(sendToRadio = { sentPackets.add(it) })
backgroundScope.launch { repeat(3) { sender.sendHeartbeat() } }
runCurrent()
assertEquals(0L, testScheduler.currentTime)
assertHeartbeats(sentPackets, 0, 1, 2)
}
@Test
fun `restarting a heartbeat loop resumes with the next nonce`() = runTest {
val sentPackets = mutableListOf<ByteArray>()
val sender = HeartbeatSender(sendToRadio = { sentPackets.add(it) })
val interval = 5.seconds
val firstJob = launchRepeatingHeartbeatLoop(sender = sender, interval = interval)
runCurrent()
advanceTimeBy(interval.inWholeMilliseconds)
runCurrent()
firstJob.cancel()
val secondJob = launchFiniteHeartbeatLoop(sender = sender, interval = interval, repeatCount = 2)
runCurrent()
advanceTimeBy(interval.inWholeMilliseconds)
runCurrent()
secondJob.cancel()
assertHeartbeats(sentPackets, 0, 1, 2, 3)
}
private fun TestScope.launchRepeatingHeartbeatLoop(sender: HeartbeatSender, interval: Duration): Job =
backgroundScope.launch {
while (isActive) {
sender.sendHeartbeat()
delay(interval)
}
}
private fun TestScope.launchFiniteHeartbeatLoop(
sender: HeartbeatSender,
interval: Duration,
repeatCount: Int,
): Job = backgroundScope.launch {
repeat(repeatCount) {
sender.sendHeartbeat()
delay(interval)
}
}
private fun assertHeartbeats(sentPackets: List<ByteArray>, vararg expectedNonces: Int) {
assertEquals(expectedNonces.size, sentPackets.size)
sentPackets.zip(expectedNonces.toList()).forEachIndexed { index, (packet, expectedNonce) ->
val message = ToRadio.ADAPTER.decode(packet)
val heartbeat = assertNotNull(message.heartbeat, "Missing heartbeat at index $index")
assertEquals(expectedNonce, heartbeat.nonce, "Unexpected nonce at index $index")
}
}
}

View File

@@ -737,6 +737,7 @@
<string name="mute_always">Always</string>
<string name="mute_notifications">Mute notifications</string>
<string name="mute_remove">Unmute notifications for '%1$s'?</string>
<string name="mute_selected">Mute selected</string>
<string name="mute_status_always">Always muted</string>
<string name="mute_status_muted_for_days">Muted for %1$d days, %2$s hours</string>
<string name="mute_status_muted_for_hours">Muted for %1$s hours</string>
@@ -1145,6 +1146,7 @@
<string name="store_forward_config">Store &amp; Forward Config</string>
<string name="store_forward_enabled">Store &amp; Forward enabled</string>
<string name="subnet">Subred</string>
<string name="success">Success</string>
<string name="super_deep_sleep_duration_seconds">Super deep sleep duration</string>
<string name="supported">Supported</string>
<string name="supported_by_community">Supported by Meshtastic Community</string>
@@ -1257,6 +1259,7 @@
<string name="unmessageable">Unmessageable</string>
<string name="unmonitored_or_infrastructure">Unmonitored or Infrastructure</string>
<string name="unmute">Unmute</string>
<string name="unmute_selected">Unmute selected</string>
<string name="unrecognized">Unrecognized</string>
<string name="unset">Unset - 0</string>
<string name="up_down_select_input_enabled">Up/Down/Select input enabled</string>
@@ -1313,6 +1316,7 @@
<string name="wifi_provision_description">Provision Wi-Fi credentials to your mPWRD-OS device via Bluetooth.</string>
<string name="wifi_provision_device_found">Device found</string>
<string name="wifi_provision_device_found_detail">Ready to scan for WiFi networks.</string>
<string name="wifi_provision_hidden_network">Hidden network</string>
<string name="wifi_provision_mpwrd_disclaimer">Learn more about the mPWRD-OS project\nhttps://github.com/mPWRD-OS</string>
<string name="wifi_provision_no_networks">No networks found</string>
<string name="wifi_provision_scan_failed">Failed to scan for WiFi networks: %1$s</string>

View File

@@ -16,7 +16,7 @@
*/
package org.meshtastic.core.service
internal object NotificationChannels {
object NotificationChannels {
const val SERVICE = "my_service"
const val MESSAGES = "my_messages"
const val BROADCASTS = "my_broadcasts"

View File

@@ -0,0 +1,167 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class DirectRadioControllerImplTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val commandSender: CommandSender = mock(MockMode.autofill)
private val router: MeshRouter = mock(MockMode.autofill)
private val actionHandler: MeshActionHandler = mock(MockMode.autofill)
private val nodeManager: NodeManager = mock(MockMode.autofill)
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val locationManager: MeshLocationManager = mock(MockMode.autofill)
private fun createController(
serviceRepository: ServiceRepository = ServiceRepositoryImpl(),
myNodeNum: Int? = 1234,
): DirectRadioControllerImpl {
every { router.actionHandler } returns actionHandler
every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum)
return DirectRadioControllerImpl(
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
commandSender = commandSender,
router = router,
nodeManager = nodeManager,
radioInterfaceService = radioInterfaceService,
locationManager = locationManager,
)
}
@Test
fun connectionStateAndClientNotificationDelegateToServiceRepository() {
val serviceRepository = ServiceRepositoryImpl()
val controller = createController(serviceRepository = serviceRepository)
val notification = ClientNotification()
assertSame(serviceRepository.connectionState, controller.connectionState)
assertSame(serviceRepository.clientNotification, controller.clientNotification)
serviceRepository.setConnectionState(ConnectionState.Connecting)
serviceRepository.setClientNotification(notification)
assertEquals(ConnectionState.Connecting, controller.connectionState.value)
assertSame(notification, controller.clientNotification.value)
controller.clearClientNotification()
assertNull(serviceRepository.clientNotification.value)
}
@Test
fun sendMessageDelegatesToActionHandlerWithLocalNodeNumber() = runTest {
val controller = createController(myNodeNum = 456)
val packet = DataPacket(to = DataPacket.ID_BROADCAST, channel = 1, text = "ping")
controller.sendMessage(packet)
verify { actionHandler.handleSend(packet, 456) }
}
@Test
fun sendSharedContactEmitsActionAndWaitsForResult() = runTest {
val serviceRepository = ServiceRepositoryImpl()
val controller = createController(serviceRepository = serviceRepository)
val nodeNum = 321
val user = User(id = DataPacket.nodeNumToDefaultId(nodeNum), long_name = "Remote Node", short_name = "RN")
val node = Node(num = nodeNum, user = user, manuallyVerified = true)
every { nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) } returns node
val emittedAction = async { serviceRepository.serviceAction.first() }
val sendResult = async { controller.sendSharedContact(nodeNum) }
val action = emittedAction.await()
assertTrue(action is ServiceAction.SendContact)
assertEquals(node.num, action.contact.node_num)
assertEquals(node.user, action.contact.user)
assertEquals(node.manuallyVerified, action.contact.manually_verified)
action.result.complete(true)
assertTrue(sendResult.await())
}
@Test
fun requestConfigOperationsDelegateToActionHandler() = runTest {
val controller = createController()
controller.getOwner(destNum = 101, packetId = 1)
controller.getConfig(destNum = 102, configType = 2, packetId = 3)
controller.getModuleConfig(destNum = 103, moduleConfigType = 4, packetId = 5)
controller.getChannel(destNum = 104, index = 6, packetId = 7)
controller.getRingtone(destNum = 105, packetId = 8)
controller.getCannedMessages(destNum = 106, packetId = 9)
controller.getDeviceConnectionStatus(destNum = 107, packetId = 10)
verify { actionHandler.handleGetRemoteOwner(1, 101) }
verify { actionHandler.handleGetRemoteConfig(3, 102, 2) }
verify { actionHandler.handleGetModuleConfig(5, 103, 4) }
verify { actionHandler.handleGetRemoteChannel(7, 104, 6) }
verify { actionHandler.handleGetRingtone(8, 105) }
verify { actionHandler.handleGetCannedMessages(9, 106) }
verify { actionHandler.handleGetDeviceConnectionStatus(10, 107) }
}
@Test
fun stopProvideLocationDelegatesToLocationManager() {
val controller = createController()
controller.stopProvideLocation()
verify { locationManager.stop() }
}
@Test
fun setDeviceAddressUpdatesLastAddressAndTransportAddress() {
val controller = createController()
controller.setDeviceAddress("tcp:192.168.1.1")
verify { actionHandler.handleUpdateLastAddress("tcp:192.168.1.1") }
verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") }
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import co.touchlab.kermit.Severity
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class)
class ServiceRepositoryImplTest {
@Test
fun initialStateExposesDefaultsAndNoBufferedEvents() = runTest {
val repository = ServiceRepositoryImpl()
assertEquals(ConnectionState.Disconnected, repository.connectionState.value)
assertNull(repository.clientNotification.value)
assertNull(repository.errorMessage.value)
assertNull(repository.connectionProgress.value)
assertNull(repository.tracerouteResponse.value)
assertNull(repository.neighborInfoResponse.value)
val initialMeshPacket = async { withTimeoutOrNull(1) { repository.meshPacketFlow.first() } }
val initialServiceAction = async { withTimeoutOrNull(1) { repository.serviceAction.first() } }
runCurrent()
advanceTimeBy(1)
assertNull(initialMeshPacket.await())
assertNull(initialServiceAction.await())
}
@Test
fun setConnectionStateUpdatesStateFlow() = runTest {
val repository = ServiceRepositoryImpl()
val emittedState = async { repository.connectionState.drop(1).first() }
runCurrent()
repository.setConnectionState(ConnectionState.Connecting)
assertEquals(ConnectionState.Connecting, emittedState.await())
assertEquals(ConnectionState.Connecting, repository.connectionState.value)
}
@Test
fun onServiceActionEmitsThroughFlow() = runTest {
val repository = ServiceRepositoryImpl()
val action = ServiceAction.GetDeviceMetadata(destNum = 42)
val emittedAction = async { repository.serviceAction.first() }
runCurrent()
repository.onServiceAction(action)
assertEquals(action, emittedAction.await())
}
@Test
fun setErrorMessageEmitsAndCanBeCleared() = runTest {
val repository = ServiceRepositoryImpl()
val emittedMessage = async { repository.errorMessage.drop(1).first() }
runCurrent()
repository.setErrorMessage("BLE connection lost", Severity.Warn)
assertEquals("BLE connection lost", emittedMessage.await())
assertEquals("BLE connection lost", repository.errorMessage.value)
repository.clearErrorMessage()
assertNull(repository.errorMessage.value)
}
@Test
fun setTracerouteResponseEmitsAndCanBeCleared() = runTest {
val repository = ServiceRepositoryImpl()
val response =
TracerouteResponse(
message = "Traceroute complete",
destinationNodeNum = 123,
requestId = 456,
forwardRoute = listOf(1, 2, 3),
returnRoute = listOf(3, 2, 1),
)
val emittedResponse = async { repository.tracerouteResponse.drop(1).first() }
runCurrent()
repository.setTracerouteResponse(response)
assertEquals(response, emittedResponse.await())
assertEquals(response, repository.tracerouteResponse.value)
repository.clearTracerouteResponse()
assertNull(repository.tracerouteResponse.value)
}
}

View File

@@ -18,8 +18,15 @@ package org.meshtastic.feature.connections.ui.components
/** Visual style for [ConnectionActionButton]. Maps to the four canonical M3 button variants. */
enum class ConnectionActionButtonStyle {
/** Solid-fill button for the primary action in a group (e.g. "Start scan"). */
Filled,
/** Tonal (filled-tonal) button for secondary prominence (e.g. "Add device manually"). */
Tonal,
/** Outlined button for neutral or tertiary actions (e.g. "Disconnect"). */
Outlined,
/** Text-only button for the least prominent action (e.g. inline toggles). */
Text,
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.firmware.ota
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.network.sockets.port
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readAvailable
import io.ktor.utils.io.readLine
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class WifiOtaTransportTest {
@Test
fun `connect succeeds when TCP socket is established`() = runTest {
val server = TestTcpOtaServer.start()
val transport = WifiOtaTransport(deviceIpAddress = LOCALHOST, port = server.port)
try {
val result = transport.connect()
assertTrue(result.isSuccess, "connect() must succeed: ${result.exceptionOrNull()}")
assertNotNull(server.awaitConnection())
} finally {
transport.close()
server.close()
}
}
@Test
fun `connect fails when device is unreachable`() = runTest {
val server = TestTcpOtaServer.start()
val port = server.port
server.close()
val transport = WifiOtaTransport(deviceIpAddress = LOCALHOST, port = port)
try {
val result = transport.connect()
assertTrue(result.isFailure)
assertNotNull(result.exceptionOrNull())
} finally {
transport.close()
}
}
@Test
fun `startOta sends OTA command and succeeds on OK response`() = runTest {
val (transport, server, connection) = createConnectedTransport()
try {
val startJob = async(Dispatchers.Default) { transport.startOta(1024L, "abc123hash") }
assertEquals("OTA 1024 abc123hash", connection.readLine())
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
} finally {
transport.close()
server.close()
}
}
@Test
fun `startOta reports erasing status before succeeding`() = runTest {
val (transport, server, connection) = createConnectedTransport()
val statuses = mutableListOf<OtaHandshakeStatus>()
try {
val startJob =
async(Dispatchers.Default) { transport.startOta(2048L, "hash256") { status -> statuses += status } }
assertEquals("OTA 2048 hash256", connection.readLine())
connection.sendResponse("ERASING")
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
assertEquals(1, statuses.size)
assertIs<OtaHandshakeStatus.Erasing>(statuses.single())
} finally {
transport.close()
server.close()
}
}
@Test
fun `startOta fails on hash rejected response`() = runTest {
val (transport, server, connection) = createConnectedTransport()
try {
val startJob = async(Dispatchers.Default) { transport.startOta(1024L, "bad-hash") }
assertEquals("OTA 1024 bad-hash", connection.readLine())
connection.sendResponse("ERR Hash Rejected")
val result = startJob.await()
assertTrue(result.isFailure)
assertIs<OtaProtocolException.HashRejected>(result.exceptionOrNull())
} finally {
transport.close()
server.close()
}
}
@Test
fun `streamFirmware sends 1024-byte chunks and waits for final OK`() = runTest {
val (transport, server, connection) = createConnectedTransport()
val firmware = ByteArray(2500) { (it % 251).toByte() }
val progressValues = mutableListOf<Float>()
try {
val startJob = async(Dispatchers.Default) { transport.startOta(firmware.size.toLong(), "firmware-hash") }
assertEquals("OTA 2500 firmware-hash", connection.readLine())
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
val streamJob =
async(Dispatchers.Default) {
transport.streamFirmware(firmware, WifiOtaTransport.RECOMMENDED_CHUNK_SIZE) { progress ->
progressValues += progress
}
}
assertContentEquals(firmware, connection.readExactly(firmware.size))
connection.sendResponse("ACK")
connection.sendResponse("OK")
assertTrue(streamJob.await().isSuccess)
assertEquals(3, progressValues.size)
assertEquals(1024f / 2500f, progressValues[0], 0.0001f)
assertEquals(2048f / 2500f, progressValues[1], 0.0001f)
assertEquals(1.0f, progressValues[2], 0.0001f)
} finally {
transport.close()
server.close()
}
}
@Test
fun `streamFirmware fails on hash mismatch verification error`() = runTest {
val (transport, server, connection) = createConnectedTransport()
val firmware = byteArrayOf(0x01, 0x02, 0x03, 0x04)
try {
val startJob = async(Dispatchers.Default) { transport.startOta(firmware.size.toLong(), "firmware-hash") }
assertEquals("OTA 4 firmware-hash", connection.readLine())
connection.sendResponse("OK")
assertTrue(startJob.await().isSuccess)
val streamJob =
async(Dispatchers.Default) {
transport.streamFirmware(firmware, WifiOtaTransport.RECOMMENDED_CHUNK_SIZE) {}
}
assertContentEquals(firmware, connection.readExactly(firmware.size))
connection.sendResponse("ERR Hash Mismatch")
val result = streamJob.await()
assertTrue(result.isFailure)
assertIs<OtaProtocolException.VerificationFailed>(result.exceptionOrNull())
} finally {
transport.close()
server.close()
}
}
@Test
fun `close resets transport and closes TCP connection`() = runTest {
val (transport, server, connection) = createConnectedTransport()
try {
transport.close()
assertNull(withTimeout(1_000L) { connection.readLine() })
val result = transport.startOta(1L, "hash")
assertTrue(result.isFailure)
assertIs<OtaProtocolException.ConnectionFailed>(result.exceptionOrNull())
} finally {
server.close()
}
}
private suspend fun createConnectedTransport(): Triple<WifiOtaTransport, TestTcpOtaServer, TestTcpOtaConnection> {
val server = TestTcpOtaServer.start()
val transport = WifiOtaTransport(deviceIpAddress = LOCALHOST, port = server.port)
val result = transport.connect()
assertTrue(result.isSuccess, "connect() must succeed: ${result.exceptionOrNull()}")
return Triple(transport, server, server.awaitConnection())
}
private class TestTcpOtaServer
private constructor(
private val selectorManager: SelectorManager,
private val serverSocket: ServerSocket,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val acceptedConnection = CompletableDeferred<TestTcpOtaConnection>()
val port: Int = serverSocket.localAddress.port()
init {
scope.launch {
runCatching {
val socket = serverSocket.accept()
acceptedConnection.complete(
TestTcpOtaConnection(
socket = socket,
readChannel = socket.openReadChannel(),
writeChannel = socket.openWriteChannel(autoFlush = true),
),
)
}
.onFailure { acceptedConnection.completeExceptionally(it) }
}
}
suspend fun awaitConnection(): TestTcpOtaConnection = acceptedConnection.await()
suspend fun close() {
if (acceptedConnection.isCompleted && !acceptedConnection.isCancelled) {
runCatching { acceptedConnection.await().close() }
}
runCatching { serverSocket.close() }
runCatching { selectorManager.close() }
scope.cancel()
}
companion object {
suspend fun start(): TestTcpOtaServer {
val selectorManager = SelectorManager(Dispatchers.Default)
val serverSocket = aSocket(selectorManager).tcp().bind(hostname = LOCALHOST, port = 0)
return TestTcpOtaServer(selectorManager, serverSocket)
}
}
}
private class TestTcpOtaConnection(
private val socket: Socket,
private val readChannel: ByteReadChannel,
private val writeChannel: ByteWriteChannel,
) {
suspend fun readLine(): String? = readChannel.readLine()
suspend fun sendResponse(text: String) {
writeChannel.writeStringUtf8("$text\n")
writeChannel.flush()
}
suspend fun readExactly(byteCount: Int): ByteArray {
val bytes = ByteArray(byteCount)
var offset = 0
while (offset < byteCount) {
readChannel.awaitContent()
val bytesRead = readChannel.readAvailable(bytes, offset, byteCount - offset)
if (bytesRead == -1) break
offset += bytesRead
}
return bytes.copyOf(offset)
}
suspend fun close() {
runCatching { socket.close() }
}
}
private companion object {
const val LOCALHOST = "127.0.0.1"
}
}

View File

@@ -37,5 +37,6 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
}
androidMain.dependencies { implementation(projects.core.service) }
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
@OptIn(ExperimentalPermissionsApi::class)
internal class AndroidIntroPermissions(
private val bluetoothState: MultiplePermissionsState,
private val locationState: MultiplePermissionsState,
private val notificationState: PermissionState?,
) : IntroPermissions {
override val bluetooth: IntroPermissionState =
object : IntroPermissionState {
override val isGranted: Boolean
get() = bluetoothState.allPermissionsGranted
override fun launchRequest() = bluetoothState.launchMultiplePermissionRequest()
}
override val location: IntroPermissionState =
object : IntroPermissionState {
override val isGranted: Boolean
get() = locationState.allPermissionsGranted
override fun launchRequest() = locationState.launchMultiplePermissionRequest()
}
override val notification: IntroPermissionState? =
notificationState?.let { state ->
object : IntroPermissionState {
override val isGranted: Boolean
get() = state.status.isGranted
override fun launchRequest() = state.launchPermissionRequest()
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import org.meshtastic.core.service.NotificationChannels
internal class AndroidIntroSettingsNavigator(private val context: Context) : IntroSettingsNavigator {
override fun openAppSettings() {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
}
override fun openCriticalAlertsSettings() {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.ALERTS)
}
context.startActivity(intent)
}
}

View File

@@ -19,6 +19,10 @@ package org.meshtastic.feature.intro
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
@@ -35,6 +39,8 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
val context = LocalContext.current
val notificationPermissionState: PermissionState? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
@@ -50,23 +56,28 @@ fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
// On older versions, location permission is used for scanning.
emptyList()
}
val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
val permissions =
remember(notificationPermissionState, locationPermissionState, bluetoothPermissionState) {
AndroidIntroPermissions(
bluetoothState = bluetoothPermissionState,
locationState = locationPermissionState,
notificationState = notificationPermissionState,
)
}
val settingsNavigator = remember(context) { AndroidIntroSettingsNavigator(context) }
val backStack = rememberNavBackStack(Welcome)
MeshtasticNavDisplay(
backStack = backStack,
entryProvider =
introNavGraph(
CompositionLocalProvider(
LocalIntroPermissions provides permissions,
LocalIntroSettingsNavigator provides settingsNavigator,
) {
MeshtasticNavDisplay(
backStack = backStack,
viewModel = viewModel,
notificationPermissionState = notificationPermissionState,
bluetoothPermissionState = bluetoothPermissionState,
locationPermissionState = locationPermissionState,
onDone = onDone,
),
)
entryProvider = entryProvider { introGraph(backStack = backStack, viewModel = viewModel, onDone = onDone) },
)
}
}

View File

@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_feature_config
import org.meshtastic.core.resources.bluetooth_feature_config_description
@@ -34,6 +32,7 @@ import org.meshtastic.core.resources.settings
import org.meshtastic.core.ui.icon.Antenna
import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for configuring Bluetooth permissions during the app introduction. It explains why Bluetooth permissions are
@@ -43,12 +42,17 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
* button.
* @param onSkip Callback invoked if the user chooses to skip Bluetooth permission setup.
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
* @param onOpenSettings Callback invoked when the user taps the settings link.
*/
@Composable
internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
val context = LocalContext.current
internal fun BluetoothScreen(
showNextButton: Boolean,
onSkip: () -> Unit,
onConfigure: () -> Unit,
onOpenSettings: () -> Unit,
) {
val annotatedString =
context.createClickableAnnotatedString(
createClickableAnnotatedString(
fullTextRes = Res.string.permission_missing_31,
linkTextRes = Res.string.settings,
tag = SETTINGS_TAG,
@@ -75,10 +79,12 @@ internal fun BluetoothScreen(showNextButton: Boolean, onSkip: () -> Unit, onConf
onSkip = onSkip,
onConfigure = onConfigure,
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_bluetooth_permissions,
onAnnotationClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
},
onAnnotationClick = { onOpenSettings() },
)
}
@PreviewLightDark
@Composable
private fun BluetoothScreenPreview() {
AppTheme { Surface { BluetoothScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) } }
}

View File

@@ -25,12 +25,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@@ -38,6 +40,7 @@ import org.meshtastic.core.resources.configure_critical_alerts
import org.meshtastic.core.resources.critical_alerts
import org.meshtastic.core.resources.critical_alerts_dnd_request_text
import org.meshtastic.core.resources.skip
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for explaining and guiding the user to configure critical alert settings. This screen is part of the app
@@ -77,3 +80,9 @@ internal fun CriticalAlertsScreen(onSkip: () -> Unit, onConfigure: () -> Unit) {
}
}
}
@PreviewLightDark
@Composable
private fun CriticalAlertsScreenPreview() {
AppTheme { Surface { CriticalAlertsScreen(onSkip = {}, onConfigure = {}) } }
}

View File

@@ -16,35 +16,17 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
/**
* Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
* Core Connection -> Shared Location -> Notifications.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
/** Navigation graph for the application introduction / onboarding flow. */
@Suppress("LongMethod")
internal fun introNavGraph(
internal fun EntryProviderScope<NavKey>.introGraph(
backStack: NavBackStack<NavKey>,
viewModel: IntroViewModel,
notificationPermissionState: PermissionState?,
bluetoothPermissionState: MultiplePermissionsState,
locationPermissionState: MultiplePermissionsState,
onDone: () -> Unit,
) = entryProvider {
val context = LocalContext.current
) {
fun navigateToNext(current: NavKey, permissionsGranted: Boolean = true) {
val next = viewModel.getNextKey(current, permissionsGranted)
if (next != null) {
@@ -57,7 +39,9 @@ internal fun introNavGraph(
entry<Welcome> { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
entry<Bluetooth> {
val isGranted = bluetoothPermissionState.allPermissionsGranted
val permissions = LocalIntroPermissions.current
val settingsNavigator = LocalIntroSettingsNavigator.current
val isGranted = permissions.bluetooth.isGranted
BluetoothScreen(
showNextButton = isGranted,
onSkip = { navigateToNext(Bluetooth) },
@@ -65,14 +49,17 @@ internal fun introNavGraph(
if (isGranted) {
navigateToNext(Bluetooth)
} else {
bluetoothPermissionState.launchMultiplePermissionRequest()
permissions.bluetooth.launchRequest()
}
},
onOpenSettings = { settingsNavigator.openAppSettings() },
)
}
entry<Location> {
val isGranted = locationPermissionState.allPermissionsGranted
val permissions = LocalIntroPermissions.current
val settingsNavigator = LocalIntroSettingsNavigator.current
val isGranted = permissions.location.isGranted
LocationScreen(
showNextButton = isGranted,
onSkip = { navigateToNext(Location) },
@@ -80,37 +67,38 @@ internal fun introNavGraph(
if (isGranted) {
navigateToNext(Location)
} else {
locationPermissionState.launchMultiplePermissionRequest()
permissions.location.launchRequest()
}
},
onOpenSettings = { settingsNavigator.openAppSettings() },
)
}
entry<Notifications> {
val isGranted = notificationPermissionState?.status?.isGranted ?: true
val permissions = LocalIntroPermissions.current
val settingsNavigator = LocalIntroSettingsNavigator.current
val notificationPermission = permissions.notification
val isGranted = notificationPermission?.isGranted ?: true
NotificationsScreen(
showNextButton = isGranted,
onSkip = onDone,
onConfigure = {
if (notificationPermissionState != null && !isGranted) {
notificationPermissionState.launchPermissionRequest()
if (notificationPermission != null && !isGranted) {
notificationPermission.launchRequest()
} else {
navigateToNext(Notifications, permissionsGranted = isGranted)
}
},
onOpenSettings = { settingsNavigator.openAppSettings() },
)
}
entry<CriticalAlerts> {
val settingsNavigator = LocalIntroSettingsNavigator.current
CriticalAlertsScreen(
onSkip = onDone,
onConfigure = {
val intent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
}
context.startActivity(intent)
settingsNavigator.openCriticalAlertsSettings()
onDone()
},
)

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import androidx.compose.runtime.staticCompositionLocalOf
/** Platform-agnostic permission state for the intro flow. */
interface IntroPermissionState {
val isGranted: Boolean
fun launchRequest()
}
/** Aggregated permission states needed by the intro onboarding flow. */
interface IntroPermissions {
val bluetooth: IntroPermissionState
val location: IntroPermissionState
val notification: IntroPermissionState?
}
/** Provides platform-specific permission states to the intro nav graph. */
@Suppress("CompositionLocalAllowlist")
val LocalIntroPermissions = staticCompositionLocalOf<IntroPermissions> { error("IntroPermissions not provided") }

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
import androidx.compose.runtime.staticCompositionLocalOf
/** Platform-agnostic navigator for opening system settings from the intro flow. */
interface IntroSettingsNavigator {
fun openAppSettings()
fun openCriticalAlertsSettings()
}
/** Provides platform-specific settings navigation to the intro screens. */
@Suppress("CompositionLocalAllowlist")
val LocalIntroSettingsNavigator =
staticCompositionLocalOf<IntroSettingsNavigator> { error("IntroSettingsNavigator not provided") }

View File

@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.intro
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -78,7 +77,7 @@ internal fun FeatureRow(feature: FeatureUIData) {
* @return An [AnnotatedString] with the specified portion styled and annotated.
*/
@Composable
internal fun Context.createClickableAnnotatedString(
internal fun createClickableAnnotatedString(
fullTextRes: StringResource,
linkTextRes: StringResource,
tag: String,

View File

@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.configure_location_permissions
import org.meshtastic.core.resources.distance_filters
@@ -38,6 +36,7 @@ import org.meshtastic.core.resources.share_location_description
import org.meshtastic.core.ui.icon.HardwareModel
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for configuring location permissions during the app introduction. It explains why location permissions are
@@ -47,12 +46,17 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
* button.
* @param onSkip Callback invoked if the user chooses to skip location permission setup.
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
* @param onOpenSettings Callback invoked when the user taps the settings link.
*/
@Composable
internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
val context = LocalContext.current
internal fun LocationScreen(
showNextButton: Boolean,
onSkip: () -> Unit,
onConfigure: () -> Unit,
onOpenSettings: () -> Unit,
) {
val annotatedString =
context.createClickableAnnotatedString(
createClickableAnnotatedString(
fullTextRes = Res.string.phone_location_description,
linkTextRes = Res.string.settings,
tag = SETTINGS_TAG,
@@ -71,12 +75,12 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi
subtitleRes = Res.string.distance_measurements_description,
),
FeatureUIData(
icon = MeshtasticIcons.HardwareModel, // Consider a different icon if appropriate
icon = MeshtasticIcons.HardwareModel,
titleRes = Res.string.distance_filters,
subtitleRes = Res.string.distance_filters_description,
),
FeatureUIData(
icon = MeshtasticIcons.LocationOn, // Consider a different icon if appropriate
icon = MeshtasticIcons.LocationOn,
titleRes = Res.string.mesh_map_location,
subtitleRes = Res.string.mesh_map_location_description,
),
@@ -89,10 +93,12 @@ internal fun LocationScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfi
onSkip = onSkip,
onConfigure = onConfigure,
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_location_permissions,
onAnnotationClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
},
onAnnotationClick = { onOpenSettings() },
)
}
@PreviewLightDark
@Composable
private fun LocationScreenPreview() {
AppTheme { Surface { LocationScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) } }
}

View File

@@ -16,11 +16,9 @@
*/
package org.meshtastic.feature.intro
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_notifications
import org.meshtastic.core.resources.configure_notification_permissions
@@ -37,6 +35,7 @@ import org.meshtastic.core.ui.icon.BatteryAlert
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Message
import org.meshtastic.core.ui.icon.Speaker
import org.meshtastic.core.ui.theme.AppTheme
/**
* Screen for configuring notification permissions during the app introduction. It explains why notification permissions
@@ -46,12 +45,17 @@ import org.meshtastic.core.ui.icon.Speaker
* button.
* @param onSkip Callback invoked if the user chooses to skip notification permission setup.
* @param onConfigure Callback invoked when the user proceeds to configure or grant permissions.
* @param onOpenSettings Callback invoked when the user taps the settings link.
*/
@Composable
internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, onConfigure: () -> Unit) {
val context = LocalContext.current
internal fun NotificationsScreen(
showNextButton: Boolean,
onSkip: () -> Unit,
onConfigure: () -> Unit,
onOpenSettings: () -> Unit,
) {
val annotatedString =
context.createClickableAnnotatedString(
createClickableAnnotatedString(
fullTextRes = Res.string.notification_permissions_description,
linkTextRes = Res.string.settings,
tag = SETTINGS_TAG,
@@ -83,10 +87,14 @@ internal fun NotificationsScreen(showNextButton: Boolean, onSkip: () -> Unit, on
onSkip = onSkip,
onConfigure = onConfigure,
configureButtonTextRes = if (showNextButton) Res.string.next else Res.string.configure_notification_permissions,
onAnnotationClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intent)
},
onAnnotationClick = { onOpenSettings() },
)
}
@PreviewLightDark
@Composable
private fun NotificationsScreenPreview() {
AppTheme {
Surface { NotificationsScreen(showNextButton = false, onSkip = {}, onConfigure = {}, onOpenSettings = {}) }
}
}

View File

@@ -25,13 +25,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@@ -48,6 +49,7 @@ import org.meshtastic.core.ui.icon.Antenna
import org.meshtastic.core.ui.icon.MeshHub
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NearMe
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
/**
@@ -80,11 +82,11 @@ internal fun WelcomeScreen(onGetStarted: () -> Unit) {
Scaffold(
bottomBar = {
IntroBottomBar(
onSkip = {}, // No skip on welcome
onSkip = {},
onConfigure = onGetStarted,
skipButtonText = "", // Not shown
skipButtonText = "",
configureButtonText = stringResource(Res.string.get_started),
showSkipButton = false, // Explicitly hide skip for welcome
showSkipButton = false,
)
},
) { innerPadding ->
@@ -114,8 +116,8 @@ internal fun WelcomeScreen(onGetStarted: () -> Unit) {
}
}
@Preview
@PreviewLightDark
@Composable
private fun WelcomeScreenPreview() {
WelcomeScreen(onGetStarted = {})
AppTheme { Surface { WelcomeScreen(onGetStarted = {}) } }
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.intro
/** JVM/Desktop stub: permissions are always granted (desktop doesn't need BLE/location onboarding). */
internal object JvmIntroPermissions : IntroPermissions {
private val grantedState =
object : IntroPermissionState {
override val isGranted: Boolean = true
override fun launchRequest() = Unit
}
override val bluetooth: IntroPermissionState = grantedState
override val location: IntroPermissionState = grantedState
override val notification: IntroPermissionState = grantedState
}
/** JVM/Desktop stub: settings navigation is a no-op. */
internal object JvmIntroSettingsNavigator : IntroSettingsNavigator {
override fun openAppSettings() = Unit
override fun openCriticalAlertsSettings() = Unit
}

View File

@@ -27,12 +27,15 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Waypoint
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -46,6 +49,7 @@ class BaseMapViewModelTest {
private lateinit var viewModel: BaseMapViewModel
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var waypointPacketsFlow: MutableStateFlow<List<DataPacket>>
private val mapPrefs: MapPrefs = mock()
private val packetRepository: PacketRepository = mock()
@@ -62,7 +66,8 @@ class BaseMapViewModelTest {
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
waypointPacketsFlow = MutableStateFlow(emptyList())
every { packetRepository.getWaypoints() } returns waypointPacketsFlow
viewModel =
BaseMapViewModel(
@@ -121,4 +126,78 @@ class BaseMapViewModelTest {
assertEquals(3, nodeRepository.nodeDBbyNum.value.size)
}
@Test
fun testWaypointsIncludeFutureExpirations() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val futureWaypoint = waypointPacket(id = 1, expire = now + 60)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(futureWaypoint)
assertEquals(mapOf(1 to futureWaypoint), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testWaypointsExcludeBoundaryExpirations() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val expiredAtNowWaypoint = waypointPacket(id = 2, expire = now)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(expiredAtNowWaypoint)
expectNoEvents()
assertEquals(emptyMap(), viewModel.waypoints.value)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testWaypointsIncludeNeverExpiringWaypoints() = runTest(testDispatcher) {
val neverExpiresWaypoint = waypointPacket(id = 3, expire = 0)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(neverExpiresWaypoint)
assertEquals(mapOf(3 to neverExpiresWaypoint), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun testWaypointsFilterMixedExpiredAndActiveWaypoints() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val expiredWaypoint = waypointPacket(id = 4, expire = now - 1)
val activeWaypoint = waypointPacket(id = 5, expire = now + 60)
val neverExpiresWaypoint = waypointPacket(id = 6, expire = 0)
viewModel.waypoints.test {
assertEquals(emptyMap(), awaitItem())
waypointPacketsFlow.value = listOf(expiredWaypoint, activeWaypoint, neverExpiresWaypoint)
assertEquals(
mapOf(
activeWaypoint.waypoint!!.id to activeWaypoint,
neverExpiresWaypoint.waypoint!!.id to neverExpiresWaypoint,
),
awaitItem(),
)
cancelAndIgnoreRemainingEvents()
}
}
private fun waypointPacket(id: Int, expire: Int): DataPacket = DataPacket(
to = DataPacket.ID_BROADCAST,
channel = 0,
waypoint = Waypoint(id = id, name = "Waypoint $id", expire = expire),
)
}

View File

@@ -86,6 +86,7 @@ import org.meshtastic.core.resources.mute_1_week
import org.meshtastic.core.resources.mute_8_hours
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_selected
import org.meshtastic.core.resources.mute_status_always
import org.meshtastic.core.resources.mute_status_muted_for_days
import org.meshtastic.core.resources.mute_status_muted_for_hours
@@ -93,6 +94,7 @@ import org.meshtastic.core.resources.mute_status_unmuted
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.resources.unmute_selected
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticImportFAB
@@ -464,11 +466,13 @@ private fun SelectionToolbar(
MeshtasticIcons.VolumeMute
},
contentDescription =
if (isAllMuted) {
"Unmute selected"
} else {
"Mute selected"
},
stringResource(
if (isAllMuted) {
Res.string.unmute_selected
} else {
Res.string.mute_selected
},
),
)
}
IconButton(onClick = onDeleteSelected) {

View File

@@ -37,6 +37,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.download
import org.meshtastic.core.resources.firmware_version
import org.meshtastic.core.resources.view_release
import org.meshtastic.core.ui.icon.Download
import org.meshtastic.core.ui.icon.LinkIcon
@@ -52,7 +53,10 @@ fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease, modifier: Modi
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = firmwareRelease.title, style = MaterialTheme.typography.titleLarge)
Text(text = "Version: ${firmwareRelease.id}", style = MaterialTheme.typography.bodyMedium)
Text(
text = stringResource(Res.string.firmware_version, firmwareRelease.id),
style = MaterialTheme.typography.bodyMedium,
)
Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { openUrl(firmwareRelease.pageUrl) }, modifier = Modifier.weight(1f)) {

View File

@@ -68,6 +68,10 @@ data class NodeDetailUiState(
val isEnsuringSession: Boolean = false,
)
internal object NodeDetailUiTextResolver {
var resolve: suspend (UiText) -> String = { it.resolve() }
}
/**
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@@ -179,13 +183,15 @@ class NodeDetailViewModel(
EnsureSessionResult.Refreshed,
-> _navigationEvents.trySend(SettingsRoute.Settings(destNum))
EnsureSessionResult.Disconnected ->
snackbarManager.showSnackbar(
UiText.Resource(Res.string.connect_radio_for_remote_admin).resolve(),
)
EnsureSessionResult.Disconnected -> {
val text = Res.string.connect_radio_for_remote_admin
snackbarManager.showSnackbar(NodeDetailUiTextResolver.resolve(UiText.Resource(text)))
}
EnsureSessionResult.Timeout ->
snackbarManager.showSnackbar(UiText.Resource(Res.string.remote_admin_unreachable).resolve())
snackbarManager.showSnackbar(
NodeDetailUiTextResolver.resolve(UiText.Resource(Res.string.remote_admin_unreachable)),
)
}
} finally {
isEnsuringSession.value = false

View File

@@ -84,6 +84,49 @@ internal val HOST_METRICS_INFO_DATA =
),
)
internal data class HostMetricsChartPoint(val time: Int, val value: Double)
internal data class HostMetricsChartData(
val load1: List<HostMetricsChartPoint> = emptyList(),
val load5: List<HostMetricsChartPoint> = emptyList(),
val load15: List<HostMetricsChartPoint> = emptyList(),
val freeMemoryMb: List<HostMetricsChartPoint> = emptyList(),
) {
val hasLoad: Boolean
get() = load1.isNotEmpty() || load5.isNotEmpty() || load15.isNotEmpty()
}
internal fun buildHostMetricsChartData(data: List<Telemetry>): HostMetricsChartData = HostMetricsChartData(
load1 =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.load1
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it / 100.0) }
},
load5 =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.load5
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it / 100.0) }
},
load15 =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.load15
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it / 100.0) }
},
freeMemoryMb =
data.mapNotNull { telemetry ->
telemetry.host_metrics
?.freemem_bytes
?.takeIf { it > 0 }
?.let { HostMetricsChartPoint(time = telemetry.time, value = it.toDouble() / BYTES_IN_MB) }
},
)
/**
* Vico chart composable that renders load averages (1m, 5m, 15m) and free memory as dual-axis line series: load on the
* start axis (fixed min 0), free memory in MB on the end axis.
@@ -103,41 +146,29 @@ internal fun HostMetricsChart(
modelProducer,
chartModifier,
->
val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } }
val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } }
val load15Data =
remember(data) { data.filter { it.host_metrics?.load15 != null && it.host_metrics!!.load15 > 0 } }
val memData =
remember(data) {
data.filter { it.host_metrics?.freemem_bytes != null && it.host_metrics!!.freemem_bytes > 0 }
}
val chartData = remember(data) { buildHostMetricsChartData(data) }
val load1Data = chartData.load1
val load5Data = chartData.load5
val load15Data = chartData.load15
val memData = chartData.freeMemoryMb
LaunchedEffect(load1Data, load5Data, load15Data, memData) {
LaunchedEffect(chartData) {
modelProducer.runTransaction {
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
if (hasLoad) {
if (chartData.hasLoad) {
lineSeries {
if (load1Data.isNotEmpty()) {
series(x = load1Data.map { it.time }, y = load1Data.map { it.host_metrics!!.load1 / 100.0 })
series(x = load1Data.map { it.time }, y = load1Data.map { it.value })
}
if (load5Data.isNotEmpty()) {
series(x = load5Data.map { it.time }, y = load5Data.map { it.host_metrics!!.load5 / 100.0 })
series(x = load5Data.map { it.time }, y = load5Data.map { it.value })
}
if (load15Data.isNotEmpty()) {
series(
x = load15Data.map { it.time },
y = load15Data.map { it.host_metrics!!.load15 / 100.0 },
)
series(x = load15Data.map { it.time }, y = load15Data.map { it.value })
}
}
}
if (memData.isNotEmpty()) {
lineSeries {
series(
x = memData.map { it.time },
y = memData.map { it.host_metrics!!.freemem_bytes.toDouble() / BYTES_IN_MB },
)
}
lineSeries { series(x = memData.map { it.time }, y = memData.map { it.value }) }
}
}
}
@@ -160,7 +191,7 @@ internal fun HostMetricsChart(
},
)
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
val hasLoad = chartData.hasLoad
val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null

View File

@@ -42,6 +42,7 @@ import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.success
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -102,7 +103,12 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM
}
val time = DateFormatter.formatDateTime(log.received_date)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val text =
if (result != null) {
stringResource(Res.string.success)
} else {
stringResource(Res.string.routing_error_no_response)
}
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
val header = stringResource(Res.string.neighbor_info)
var expanded by remember { mutableStateOf(false) }

View File

@@ -24,8 +24,10 @@ import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.di.CoroutineDispatchers
@@ -37,6 +39,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
@@ -136,4 +139,144 @@ class CompassViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `uiState uses PDOP only positional accuracy`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
PDOP = 250,
),
)
assertEquals("12 m", state.errorRadiusText)
}
@Test
fun `uiState uses HDOP and VDOP positional accuracy when PDOP is missing`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
HDOP = 300,
VDOP = 400,
),
)
assertEquals("25 m", state.errorRadiusText)
}
@Test
fun `uiState uses HDOP only positional accuracy when VDOP is missing`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
HDOP = 175,
),
)
assertEquals("8 m", state.errorRadiusText)
}
@Test
fun `uiState falls back to precision bits positional accuracy`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
precision_bits = 15,
),
)
assertEquals("729 m", state.errorRadiusText)
}
@Test
fun `uiState leaves positional accuracy empty when no DOP or precision data exists`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10010000,
time = 1,
gps_accuracy = 5000,
),
)
assertNull(state.errorRadiusText)
assertNull(state.angularErrorDeg)
}
@Test
fun `uiState returns 180 degree angular error when distance is zero`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000000,
longitude_i = 10000000,
time = 1,
gps_accuracy = 5000,
PDOP = 250,
),
location = PhoneLocation(1.0, 1.0, 0.0, 1000L),
)
assertEquals("12 m", state.errorRadiusText)
assertNotNull(state.angularErrorDeg)
assertEquals(180f, state.angularErrorDeg)
}
@Test
fun `uiState handles angular error for very small distances`() = runTest {
val state =
startAndGetUiState(
targetPosition =
org.meshtastic.proto.Position(
latitude_i = 10000100,
longitude_i = 10000000,
time = 1,
gps_accuracy = 5000,
PDOP = 250,
),
location = PhoneLocation(1.0, 1.0, 0.0, 1000L),
)
assertEquals("12 m", state.errorRadiusText)
assertNotNull(state.angularErrorDeg)
assertEquals(85.4f, state.angularErrorDeg, 0.5f)
}
private suspend fun TestScope.startAndGetUiState(
targetPosition: org.meshtastic.proto.Position,
location: PhoneLocation = PhoneLocation(1.0, 1.0, 0.0, 1000L),
): CompassUiState {
viewModel.start(
Node(num = 1234, user = User(id = "!1234"), position = targetPosition),
Config.DisplayConfig.DisplayUnits.METRIC,
)
locationFlow.value = PhoneLocationState(permissionGranted = true, providerEnabled = true, location = location)
runCurrent()
return viewModel.uiState.value
}
}

View File

@@ -16,13 +16,16 @@
*/
package org.meshtastic.feature.node.detail
import androidx.compose.material3.SnackbarDuration
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,13 +33,20 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
import org.meshtastic.core.domain.usecase.session.EnsureSessionResult
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.connect_radio_for_remote_admin
import org.meshtastic.core.resources.remote_admin_unreachable
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
@@ -58,7 +68,7 @@ class NodeDetailViewModelTest {
private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock()
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
private val snackbarManager: SnackbarManager = mock()
private val snackbarManager = RecordingSnackbarManager()
@BeforeTest
fun setUp() {
@@ -66,6 +76,19 @@ class NodeDetailViewModelTest {
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
snackbarManager.messages.clear()
NodeDetailUiTextResolver.resolve = { text ->
when (text) {
is UiText.DynamicString -> text.value
is UiText.Resource ->
when (text.res) {
Res.string.connect_radio_for_remote_admin -> "Connect to a radio to administer remote nodes."
Res.string.remote_admin_unreachable -> "Could not reach node — try again or move closer."
else -> error("Unexpected UiText resource in test: ${text.res}")
}
}
}
viewModel = createViewModel(1234)
}
@@ -81,8 +104,23 @@ class NodeDetailViewModelTest {
snackbarManager = snackbarManager,
)
private class RecordingSnackbarManager : SnackbarManager() {
val messages = mutableListOf<String>()
override fun showSnackbar(
message: String,
actionLabel: String?,
withDismissAction: Boolean,
duration: SnackbarDuration,
onAction: (() -> Unit)?,
) {
messages += message
}
}
@AfterTest
fun tearDown() {
NodeDetailUiTextResolver.resolve = { it.resolve() }
Dispatchers.resetMain()
}
@@ -126,4 +164,42 @@ class NodeDetailViewModelTest {
verify { nodeRequestActions.requestTraceroute(any(), 1234, "Test Node") }
}
@Test
fun `openRemoteAdmin navigates to settings when session is already active`() = runTest(testDispatcher) {
everySuspend { ensureRemoteAdminSession(1234) } returns EnsureSessionResult.AlreadyActive
viewModel.navigationEvents.test {
viewModel.openRemoteAdmin(1234)
assertEquals(SettingsRoute.Settings(1234), awaitItem())
cancelAndIgnoreRemainingEvents()
}
verifySuspend { ensureRemoteAdminSession(1234) }
}
@Test
fun `openRemoteAdmin shows disconnected snackbar when radio is disconnected`() = runTest(testDispatcher) {
everySuspend { ensureRemoteAdminSession(1234) } returns EnsureSessionResult.Disconnected
val expectedMessage = "Connect to a radio to administer remote nodes."
viewModel.openRemoteAdmin(1234)
runCurrent()
assertEquals(listOf(expectedMessage), snackbarManager.messages)
verifySuspend { ensureRemoteAdminSession(1234) }
}
@Test
fun `openRemoteAdmin shows timeout snackbar when node is unreachable`() = runTest(testDispatcher) {
everySuspend { ensureRemoteAdminSession(1234) } returns EnsureSessionResult.Timeout
val expectedMessage = "Could not reach node — try again or move closer."
viewModel.openRemoteAdmin(1234)
runCurrent()
assertEquals(listOf(expectedMessage), snackbarManager.messages)
verifySuspend { ensureRemoteAdminSession(1234) }
}
}

View File

@@ -227,6 +227,14 @@ class EnvironmentMetricsForGraphingTest {
assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal])
}
@Test
fun nanHumidity_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = Float.NaN)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal])
}
@Test
fun nanPressure_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN)))
@@ -237,6 +245,71 @@ class EnvironmentMetricsForGraphingTest {
assertEquals(0f, result.leftMinMax.second, 0.01f)
}
@Test
fun mixedValidAndNanValues_onlyValidEnvironmentMetricsAreCharted() {
val metrics =
listOf(
telemetry(
env =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = 50f,
barometric_pressure = Float.NaN,
),
),
telemetry(
env =
EnvironmentMetrics(
temperature = 20f,
relative_humidity = Float.NaN,
barometric_pressure = 1015f,
),
),
telemetry(
env = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f, barometric_pressure = 1020f),
),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
assertEquals(1015f, result.leftMinMax.first, 0.01f)
assertEquals(1020f, result.leftMinMax.second, 0.01f)
assertEquals(20f, result.rightMinMax.first, 0.01f)
assertEquals(60f, result.rightMinMax.second, 0.01f)
}
@Test
fun allNanValues_returnDefaultAxesAndNoPlots() {
val metrics =
listOf(
telemetry(
env =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = Float.NaN,
barometric_pressure = Float.NaN,
),
),
telemetry(
env =
EnvironmentMetrics(
temperature = Float.NaN,
relative_humidity = Float.NaN,
barometric_pressure = Float.NaN,
),
),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot.none { it })
assertEquals(0f, result.leftMinMax.first, 0.01f)
assertEquals(0f, result.leftMinMax.second, 0.01f)
assertEquals(0f, result.rightMinMax.first, 0.01f)
assertEquals(1f, result.rightMinMax.second, 0.01f)
}
// ---- Multiple metrics combined ----
@Test

View File

@@ -46,6 +46,11 @@ class FormatBytesTest {
assertEquals("1.5 KB", formatBytes(1536L))
}
@Test
fun kilobytes_just_below_megabyte_boundary_round_up_without_switching_units() {
assertEquals("1024 KB", formatBytes(1_048_575L))
}
@Test
fun megabyte_boundary() {
assertEquals("1 MB", formatBytes(1024L * 1024))
@@ -91,4 +96,10 @@ class FormatBytesTest {
// 1536 bytes = 1.5 KB, with 1 decimal place → 1.5 KB
assertEquals("1.5 KB", formatBytes(1536L, decimalPlaces = 1))
}
@Test
fun default_rounding_keeps_two_decimal_places_without_trailing_zeroes() {
assertEquals("1.46 KB", formatBytes(1500L))
assertEquals("1.5 KB", formatBytes(1536L))
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class HostMetricsTest {
private fun telemetry(time: Int, hostMetrics: HostMetrics? = null) =
Telemetry(time = time, host_metrics = hostMetrics)
@Test
fun buildHostMetricsChartData_filters_missing_and_non_positive_values() {
val chartData =
buildHostMetricsChartData(
listOf(
telemetry(
time = 100,
hostMetrics = HostMetrics(load1 = 150, load5 = 0, load15 = 225, freemem_bytes = 2_097_152L),
),
telemetry(time = 200, hostMetrics = HostMetrics(load1 = 0, load5 = 320, freemem_bytes = 0L)),
telemetry(time = 300, hostMetrics = null),
),
)
assertTrue(chartData.hasLoad)
assertEquals(listOf(HostMetricsChartPoint(time = 100, value = 1.5)), chartData.load1)
assertEquals(listOf(HostMetricsChartPoint(time = 200, value = 3.2)), chartData.load5)
assertEquals(listOf(HostMetricsChartPoint(time = 100, value = 2.25)), chartData.load15)
assertEquals(listOf(HostMetricsChartPoint(time = 100, value = 2.0)), chartData.freeMemoryMb)
}
@Test
fun buildHostMetricsChartData_returns_empty_series_when_no_plottable_metrics_exist() {
val chartData =
buildHostMetricsChartData(
listOf(
telemetry(
time = 100,
hostMetrics = HostMetrics(load1 = 0, load5 = 0, load15 = 0, freemem_bytes = 0L),
),
telemetry(time = 200, hostMetrics = HostMetrics()),
telemetry(time = 300, hostMetrics = null),
),
)
assertFalse(chartData.hasLoad)
assertTrue(chartData.load1.isEmpty())
assertTrue(chartData.load5.isEmpty())
assertTrue(chartData.load15.isEmpty())
assertTrue(chartData.freeMemoryMb.isEmpty())
}
}

View File

@@ -47,7 +47,11 @@ import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -225,4 +229,203 @@ class MetricsViewModelTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `saveDeviceMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
Telemetry(
time = 1700000000,
device_metrics =
DeviceMetrics(
battery_level = 80,
voltage = 4.1f,
channel_utilization = 12.5f,
air_util_tx = 3.25f,
uptime_seconds = 3600,
),
)
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(deviceMetrics = listOf(testTelemetry))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
awaitItem()
val uri = CommonUri.parse("content://test")
vm.saveDeviceMetricsCSV(uri, listOf(testTelemetry))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
"\"date\",\"time\",\"batteryLevel\",\"voltage\",\"channelUtilization\",\"airUtilTx\",\"uptimeSeconds\"",
),
)
assertTrue(csvOutput.contains("\"80\",\"4.1\",\"12.5\",\"3.25\",\"3600\""))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `saveEnvironmentMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
Telemetry(
time = 1700000000,
environment_metrics =
EnvironmentMetrics(
temperature = 21.5f,
relative_humidity = 55.5f,
barometric_pressure = 1013.25f,
gas_resistance = 12.3f,
iaq = 42,
wind_speed = 5.5f,
wind_direction = 180,
soil_temperature = 18.75f,
soil_moisture = 65,
one_wire_temperature = listOf(1f, 2f, 3f),
),
)
val nodeDetailFlow =
MutableStateFlow(
NodeDetailUiState(
metricsState = MetricsState(deviceMetrics = emptyList()),
environmentState = EnvironmentMetricsState(environmentMetrics = listOf(testTelemetry)),
),
)
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
val uri = CommonUri.parse("content://test")
vm.saveEnvironmentMetricsCSV(uri, listOf(testTelemetry))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
"\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\",\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\",\"soilMoisture\",\"oneWireTemp1\",\"oneWireTemp2\",\"oneWireTemp3\",\"oneWireTemp4\",\"oneWireTemp5\",\"oneWireTemp6\",\"oneWireTemp7\",\"oneWireTemp8\"",
),
)
assertTrue(
csvOutput.contains(
"\"21.5\",\"55.5\",\"1013.25\",\"12.3\",\"42\",\"5.5\",\"180\",\"18.75\",\"65\",\"1.0\",\"2.0\",\"3.0\",\"\",\"\",\"\",\"\",\"\"",
),
)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `saveSignalMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testPacket = MeshPacket(rx_time = 1700000000, rx_rssi = -105, rx_snr = 7.5f)
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(signalMetrics = listOf(testPacket))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
awaitItem()
val uri = CommonUri.parse("content://test")
vm.saveSignalMetricsCSV(uri, listOf(testPacket))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(csvOutput.startsWith("\"date\",\"time\",\"rssi\",\"snr\""))
assertTrue(csvOutput.contains("\"-105\",\"7.5\""))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `savePowerMetricsCSV writes correct data`() = runTest(testDispatcher) {
val testTelemetry =
Telemetry(
time = 1700000000,
power_metrics =
PowerMetrics(
ch1_voltage = 3.3f,
ch1_current = 0.1f,
ch2_voltage = 5.0f,
ch2_current = 0.2f,
ch3_voltage = 12.0f,
ch3_current = 0.3f,
),
)
val nodeDetailFlow =
MutableStateFlow(NodeDetailUiState(metricsState = MetricsState(powerMetrics = listOf(testTelemetry))))
every { getNodeDetailsUseCase(1234) } returns nodeDetailFlow.asStateFlow()
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val vm = createViewModel()
vm.state.test {
awaitItem()
awaitItem()
val uri = CommonUri.parse("content://test")
vm.savePowerMetricsCSV(uri, listOf(testTelemetry))
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(
csvOutput.startsWith(
"\"date\",\"time\",\"ch1Voltage\",\"ch1Current\",\"ch2Voltage\",\"ch2Current\",\"ch3Voltage\",\"ch3Current\"",
),
)
assertTrue(csvOutput.contains("\"3.3\",\"0.1\",\"5.0\",\"0.2\",\"12.0\",\"0.3\""))
cancelAndIgnoreRemainingEvents()
}
}
}

View File

@@ -18,9 +18,13 @@ package org.meshtastic.feature.settings
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verifySuspend
import io.kotest.matchers.ints.shouldBeInRange
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
@@ -35,7 +39,11 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
@@ -47,6 +55,7 @@ import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCas
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.testing.FakeAppPreferences
@@ -56,12 +65,18 @@ import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeNotificationPrefs
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
@@ -255,6 +270,81 @@ class SettingsViewModelTest {
appPreferences.ui.shouldProvideNodeLocation(myNodeNum).value shouldBe false
}
@Test
fun `saveDataCsv writes filtered export via file service`() = runTest {
val myNodeNum = 456
val senderNodeNum = 123
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
nodeRepository.setNodes(
listOf(TestDataFactory.createTestNode(num = senderNodeNum, longName = "Sender Node", shortName = "SN")),
)
meshLogRepository.setLogs(
listOf(
MeshLog(
uuid = "match",
message_type = "TEXT",
received_date = 1_700_000_000_000,
raw_message = "",
fromNum = senderNodeNum,
portNum = PortNum.TEXT_MESSAGE_APP.value,
fromRadio =
FromRadio(
packet =
MeshPacket(
from = senderNodeNum,
rx_snr = 5.0f,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "Hello settings".encodeUtf8(),
),
),
),
),
MeshLog(
uuid = "filtered-out",
message_type = "RANGE",
received_date = 1_700_000_001_000,
raw_message = "",
fromNum = senderNodeNum,
portNum = PortNum.RANGE_TEST_APP.value,
fromRadio =
FromRadio(
packet =
MeshPacket(
from = senderNodeNum,
rx_snr = 6.0f,
decoded = Data(
portnum = PortNum.RANGE_TEST_APP,
payload = "Ignore me".encodeUtf8(),
),
),
),
),
),
)
val buffer = Buffer()
everySuspend { fileService.write(any(), any()) } calls
{ args ->
val block = args.arg<suspend (BufferedSink) -> Unit>(1)
block(buffer)
true
}
val uri = CommonUri.parse("content://test/export.csv")
viewModel.saveDataCsv(uri, filterPortnum = PortNum.TEXT_MESSAGE_APP.value)
runCurrent()
verifySuspend { fileService.write(uri, any()) }
val csvOutput = buffer.readUtf8()
assertTrue(csvOutput.startsWith("\"date\",\"time\",\"from\""))
assertTrue(csvOutput.contains("\"123\",\"Sender Node\""))
assertTrue(csvOutput.contains("Hello settings"))
assertFalse(csvOutput.contains("Ignore me"))
}
@Test
fun `setDbCacheLimit updates manager`() = runTest {
viewModel.setDbCacheLimit(200)

View File

@@ -0,0 +1,254 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import androidx.lifecycle.SavedStateHandle
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.LocationService
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.Position
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileRoundTripTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
private val locationRepository: LocationRepository = mock(MockMode.autofill)
private val mapConsentPrefs: MapConsentPrefs = mock(MockMode.autofill)
private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill)
private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill)
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill)
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill)
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill)
private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill)
private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill)
private val adminActionsUseCase: AdminActionsUseCase = mock(MockMode.autofill)
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill)
private val locationService: LocationService = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private lateinit var fileService: InMemoryFileService
private lateinit var viewModel: RadioConfigViewModel
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
fileService = InMemoryFileService()
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
every { radioConfigRepository.deviceUIConfigFlow } returns MutableStateFlow(null)
every { radioConfigRepository.fileManifestFlow } returns MutableStateFlow(emptyList())
every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(false)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow<MeshPacket>()
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
every { mqttManager.mqttConnectionState } returns
MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive)
viewModel =
RadioConfigViewModel(
savedStateHandle = SavedStateHandle(),
radioConfigRepository = radioConfigRepository,
packetRepository = packetRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
locationRepository = locationRepository,
mapConsentPrefs = mapConsentPrefs,
analyticsPrefs = analyticsPrefs,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
toggleAnalyticsUseCase = toggleAnalyticsUseCase,
toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
importProfileUseCase = ImportProfileUseCase(),
exportProfileUseCase = ExportProfileUseCase(),
exportSecurityConfigUseCase = exportSecurityConfigUseCase,
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
processRadioResponseUseCase = processRadioResponseUseCase,
locationService = locationService,
fileService = fileService,
mqttManager = mqttManager,
)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `profile export then import round trips representative DeviceProfile`() = runTest {
assertRoundTrip(
DeviceProfile(
long_name = "Round Trip Node",
short_name = "RTN",
channel_url = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ",
config =
LocalConfig(
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER, button_gpio = 7),
lora = Config.LoRaConfig(hop_limit = 5, use_preset = true),
power = Config.PowerConfig(is_power_saving = true, ls_secs = 300),
network =
Config.NetworkConfig(
wifi_enabled = true,
wifi_ssid = "mesh-ssid",
wifi_psk = "mesh-pass",
ntp_server = "meshtastic.pool.ntp.org",
),
),
module_config =
LocalModuleConfig(
mqtt =
ModuleConfig.MQTTConfig(
enabled = true,
proxy_to_client_enabled = true,
root = "msh/US/test",
json_enabled = true,
),
telemetry =
ModuleConfig.TelemetryConfig(
device_update_interval = 300,
environment_measurement_enabled = true,
power_measurement_enabled = true,
),
canned_message =
ModuleConfig.CannedMessageConfig(
rotary1_enabled = true,
inputbroker_pin_a = 12,
inputbroker_pin_b = 13,
send_bell = true,
),
statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Ready to mesh"),
),
fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138),
ringtone = "tones/notify.mp3",
canned_messages = "Alpha|Bravo|Charlie",
),
)
}
@Test
fun `profile export then import round trips empty DeviceProfile`() = runTest { assertRoundTrip(DeviceProfile()) }
@Test
fun `profile export then import round trips partially populated DeviceProfile`() = runTest {
assertRoundTrip(
DeviceProfile(
long_name = "Partial Node",
module_config =
LocalModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Standing by")),
),
)
}
private suspend fun TestScope.assertRoundTrip(profile: DeviceProfile) {
val exportUri = CommonUri.parse("content://test/profile.bin")
val reExportUri = CommonUri.parse("content://test/profile-reexport.bin")
var importedProfile: DeviceProfile? = null
viewModel.exportProfile(exportUri, profile)
runCurrent()
viewModel.importProfile(exportUri) { importedProfile = it }
runCurrent()
val actualImportedProfile = assertNotNull(importedProfile)
assertEquals(profile, actualImportedProfile)
assertContentEquals(profile.encode(), fileService.readBytes(exportUri))
assertContentEquals(profile.encode(), actualImportedProfile.encode())
viewModel.exportProfile(reExportUri, actualImportedProfile)
runCurrent()
assertContentEquals(fileService.readBytes(exportUri), fileService.readBytes(reExportUri))
}
private class InMemoryFileService : FileService {
private val files = mutableMapOf<String, ByteArray>()
override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean {
val buffer = Buffer()
block(buffer)
files[uri.toString()] = buffer.readByteArray()
return true
}
override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean {
val bytes = files[uri.toString()] ?: return false
block(Buffer().write(bytes))
return true
}
fun readBytes(uri: CommonUri): ByteArray = files[uri.toString()] ?: error("Missing file for $uri")
}
}

View File

@@ -19,6 +19,7 @@ package org.meshtastic.feature.settings.radio
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
@@ -28,6 +29,7 @@ import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -46,6 +48,7 @@ import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
@@ -221,6 +224,68 @@ class RadioConfigViewModelTest {
}
}
@Test
fun `probeMqttConnection updates status for success`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", true, "user", "pass") }
.calls {
delay(1)
MqttProbeStatus.Success(serverInfo = "client=test")
}
viewModel.probeMqttConnection("mqtt.example.com", true, "user", "pass")
assertEquals(MqttProbeStatus.Probing, viewModel.mqttProbeStatus.value)
advanceTimeBy(1)
runCurrent()
assertEquals(MqttProbeStatus.Success(serverInfo = "client=test"), viewModel.mqttProbeStatus.value)
verifySuspend { mqttManager.probe("mqtt.example.com", true, "user", "pass") }
}
@Test
fun `probeMqttConnection updates status for timeout`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", false, null, null) } returns MqttProbeStatus.Timeout(5_000)
viewModel.probeMqttConnection("mqtt.example.com", false, null, null)
runCurrent()
assertEquals(MqttProbeStatus.Timeout(5_000), viewModel.mqttProbeStatus.value)
verifySuspend { mqttManager.probe("mqtt.example.com", false, null, null) }
}
@Test
fun `probeMqttConnection converts thrown exception to other status`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", true, null, null) }
.calls { throw IllegalStateException("boom") }
viewModel.probeMqttConnection("mqtt.example.com", true, null, null)
runCurrent()
assertEquals(MqttProbeStatus.Other(message = "boom"), viewModel.mqttProbeStatus.value)
verifySuspend { mqttManager.probe("mqtt.example.com", true, null, null) }
}
@Test
fun `clearMqttProbeStatus resets probe state`() = runTest {
everySuspend { mqttManager.probe("mqtt.example.com", false, null, null) }
.calls {
delay(1)
MqttProbeStatus.Success(serverInfo = "client=test")
}
viewModel.probeMqttConnection("mqtt.example.com", false, null, null)
assertEquals(MqttProbeStatus.Probing, viewModel.mqttProbeStatus.value)
viewModel.clearMqttProbeStatus()
assertEquals(null, viewModel.mqttProbeStatus.value)
advanceTimeBy(1)
runCurrent()
assertEquals(null, viewModel.mqttProbeStatus.value)
}
@Test
fun `updateChannels calls useCase for each changed channel`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))

View File

@@ -169,7 +169,7 @@ class WifiProvisionViewModel(
* @param ssid The target network SSID.
* @param password The network password (empty string for open networks).
*/
fun provisionWifi(ssid: String, password: String) {
fun provisionWifi(ssid: String, password: String, hidden: Boolean = false) {
if (ssid.isBlank()) return
val nymeaService = service ?: return
@@ -183,7 +183,7 @@ class WifiProvisionViewModel(
}
viewModelScope.launch {
when (val result = nymeaService.provision(ssid, password)) {
when (val result = nymeaService.provision(ssid, password, hidden)) {
is ProvisionResult.Success -> {
Logger.i { "$TAG: Provisioned successfully" }
_uiState.update {

View File

@@ -71,7 +71,7 @@ private val manyNetworks =
}
private val noOp: () -> Unit = {}
private val noOpProvision: (String, String) -> Unit = { _, _ -> }
private val noOpProvision: (String, String, Boolean) -> Unit = { _, _, _ -> }
// ---------------------------------------------------------------------------
// Phase 1: BLE scanning

View File

@@ -66,6 +66,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -101,12 +102,14 @@ import org.meshtastic.core.resources.hide_password
import org.meshtastic.core.resources.img_mpwrd_logo
import org.meshtastic.core.resources.mpwrd_os
import org.meshtastic.core.resources.password
import org.meshtastic.core.resources.retry
import org.meshtastic.core.resources.show_password
import org.meshtastic.core.resources.wifi_provision_available_networks
import org.meshtastic.core.resources.wifi_provision_connect_failed
import org.meshtastic.core.resources.wifi_provision_description
import org.meshtastic.core.resources.wifi_provision_device_found
import org.meshtastic.core.resources.wifi_provision_device_found_detail
import org.meshtastic.core.resources.wifi_provision_hidden_network
import org.meshtastic.core.resources.wifi_provision_mpwrd_disclaimer
import org.meshtastic.core.resources.wifi_provision_no_networks
import org.meshtastic.core.resources.wifi_provision_scan_failed
@@ -206,7 +209,11 @@ fun WifiProvisionScreen(
Crossfade(targetState = screenKey(uiState), label = "wifi_provision") { key ->
when (key) {
ScreenKey.ConnectingBle -> ScanningBleContent()
ScreenKey.ConnectingBle ->
ScanningBleContent(
error = uiState.error as? WifiProvisionError.ConnectFailed,
onRetry = { viewModel.connectToDevice(address) },
)
ScreenKey.DeviceFound ->
DeviceFoundContent(
@@ -272,11 +279,29 @@ private val Phase.isLoading: Boolean
/** BLE scanning spinner — shown while searching for a device. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ScanningBleContent() {
internal fun ScanningBleContent(error: WifiProvisionError.ConnectFailed? = null, onRetry: () -> Unit = {}) {
CenteredStatusContent {
LoadingIndicator(modifier = Modifier.size(48.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.wifi_provision_scanning_ble), style = MaterialTheme.typography.bodyLarge)
if (error != null) {
Icon(
MeshtasticIcons.Bluetooth,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(Res.string.wifi_provision_connect_failed, error.detail),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = onRetry) { Text(stringResource(Res.string.retry)) }
} else {
LoadingIndicator(modifier = Modifier.size(48.dp))
Spacer(Modifier.height(24.dp))
Text(stringResource(Res.string.wifi_provision_scanning_ble), style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -349,7 +374,7 @@ internal fun ConnectedContent(
isProvisioning: Boolean,
isScanning: Boolean,
onScanNetworks: () -> Unit,
onProvision: (ssid: String, password: String) -> Unit,
onProvision: (ssid: String, password: String, hidden: Boolean) -> Unit,
onDisconnect: () -> Unit,
) {
if (provisionStatus == ProvisionStatus.Success) {
@@ -360,6 +385,7 @@ internal fun ConnectedContent(
var ssid by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var hiddenNetwork by rememberSaveable { mutableStateOf(false) }
val haptic = LocalHapticFeedback.current
LaunchedEffect(provisionStatus) {
@@ -472,10 +498,20 @@ internal fun ConnectedContent(
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onProvision(ssid, password) }),
keyboardActions = KeyboardActions(onDone = { onProvision(ssid, password, hiddenNetwork) }),
modifier = Modifier.fillMaxWidth(),
)
// Hidden network toggle
Row(
modifier = Modifier.fillMaxWidth().clickable(role = Role.Switch) { hiddenNetwork = !hiddenNetwork },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(Res.string.wifi_provision_hidden_network), style = MaterialTheme.typography.bodyMedium)
Switch(checked = hiddenNetwork, onCheckedChange = { hiddenNetwork = it })
}
// Inline provision status (matches web flasher's status chip) — animated entrance
AnimatedVisibility(
visible = provisionStatus != ProvisionStatus.Idle || isProvisioning,
@@ -489,7 +525,7 @@ internal fun ConnectedContent(
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onDisconnect) { Text(stringResource(Res.string.cancel)) }
Button(
onClick = { onProvision(ssid, password) },
onClick = { onProvision(ssid, password, hiddenNetwork) },
enabled = ssid.isNotBlank() && !isProvisioning,
modifier = Modifier.weight(1f),
) {

View File

@@ -24,7 +24,7 @@ ksp.run.in.process=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.isolated-projects=true
org.gradle.jvmargs=-Xmx8g -XX:+UseZGC -XX:+ZGenerational -XX:+UseStringDeduplication -XX:ReservedCodeCacheSize=512m -XX:MaxMetaspaceSize=2g -Xss2m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx8g -XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:+UseStringDeduplication -XX:ReservedCodeCacheSize=512m -XX:MaxMetaspaceSize=2g -Xss2m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.vfs.watch=true
org.gradle.welcome=never

View File

@@ -227,7 +227,7 @@
## Gap Tasks (Identified During Migration)
### MSG-T029: Fix hardcoded English strings in SelectionToolbar [ ]
### MSG-T029: Fix hardcoded English strings in SelectionToolbar [x]
- **File**: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt` (lines 468470)
- **Issue**: `contentDescription` for mute/unmute icons uses hardcoded `"Mute selected"` / `"Unmute selected"` instead of `stringResource()`.

View File

@@ -160,9 +160,9 @@
### Gap Tasks (Not Yet Implemented) ⚠️
- [ ] DC-T036 `[GAP]` [US1] Write `AndroidScannerViewModelTest` in `feature/connections/src/androidTest/` — test `requestBonding()` success/failure paths, `requestPermission()` USB flow, `SecurityException` handling, "bond state 11" special case. *Rationale: Android-specific bonding and permission logic has no test coverage.*
- [ ] DC-T037 `[GAP]` [US1/US5] Write Compose UI tests for `ConnectionsScreen` in `feature/connections/src/commonTest/` — test `AnimatedContent` state transitions (NO_DEVICE → CONNECTING → CONNECTED), transport chip toggles, device card selection. *Rationale: All existing tests are ViewModel/use-case level; no UI-layer test coverage.*
- [ ] DC-T038 `[GAP]` Add KDoc to `ConnectionActionButtonStyle.kt` — document each enum value (`Filled`, `Tonal`, `Outlined`, `Text`) with usage context. *Rationale: Only enum in the module without documentation.*
- [ ] **[DEFERRED]** DC-T036 `[GAP]` [US1] Write `AndroidScannerViewModelTest` in `feature/connections/src/androidTest/` — test `requestBonding()` success/failure paths, `requestPermission()` USB flow, `SecurityException` handling, "bond state 11" special case. *Rationale: Android-specific bonding and permission logic has no test coverage.**Deferred: requires Android instrumented test (androidTest) for bonding/permission APIs.*
- [ ] **[DEFERRED]** DC-T037 `[GAP]` [US1/US5] Write Compose UI tests for `ConnectionsScreen` in `feature/connections/src/commonTest/` — test `AnimatedContent` state transitions (NO_DEVICE → CONNECTING → CONNECTED), transport chip toggles, device card selection. *Rationale: All existing tests are ViewModel/use-case level; no UI-layer test coverage.**Deferred: requires Compose UI test infrastructure.*
- [x] DC-T038 `[GAP]` Add KDoc to `ConnectionActionButtonStyle.kt` — document each enum value (`Filled`, `Tonal`, `Outlined`, `Text`) with usage context. *Rationale: Only enum in the module without documentation.*
---

View File

@@ -254,13 +254,13 @@
## Identified Gaps
- [ ] **FW-T059**: Add `WifiOtaTransport` unit tests
- [x] **FW-T059**: Add `WifiOtaTransport` unit tests
The WiFi/TCP OTA transport has no dedicated test coverage. Should test connection, command sending, response reading, firmware streaming, and error handling using a fake Ktor socket.
- [ ] **FW-T060**: Add `FirmwareUpdateScreen` composable/screenshot tests
- [ ] **[DEFERRED]** **FW-T060**: Add `FirmwareUpdateScreen` composable/screenshot tests*Deferred: requires Compose UI test infrastructure.*
No UI tests exist for the 889-line screen composable. Should test at minimum: Ready state rendering, progress state rendering, error state rendering, and success state rendering.
- [ ] **FW-T061**: Add `Esp32OtaUpdateHandler` unit tests
- [ ] **[DEFERRED]** **FW-T061**: Add `Esp32OtaUpdateHandler` unit tests*Deferred: requires integration test harness for TCP OTA protocol — mock dispatchers incompatible with real sockets.*
The ESP32 OTA handler orchestration logic (firmware retrieval → hash → reboot → connect → stream) has no isolated test. Currently only covered by proxy through integration tests.
---

View File

@@ -292,33 +292,33 @@
## Identified Gaps
### NDM-T100: Missing — MetricsViewModel CSV export tests for device/environment/signal/power
- [ ] Add unit tests for `saveDeviceMetricsCSV`, `saveEnvironmentMetricsCSV`, `saveSignalMetricsCSV`, `savePowerMetricsCSV` verifying correct column headers and data formatting
- [x] Add unit tests for `saveDeviceMetricsCSV`, `saveEnvironmentMetricsCSV`, `saveSignalMetricsCSV`, `savePowerMetricsCSV` verifying correct column headers and data formatting
- **Rationale**: Only `savePositionCSV` has a test; the other four export methods are untested.
- **Priority**: Medium
### NDM-T101: Missing — HostMetricsLogScreen chart+card test coverage
- [ ] Add unit tests for `HostMetricsChart` data model and `formatBytes` edge cases (exact boundaries)
- [x] Add unit tests for `HostMetricsChart` data model and `formatBytes` edge cases (exact boundaries)
- **Rationale**: `formatBytes` is tested but chart data transformation and card selection sync are not.
- **Priority**: Low
### NDM-T102: Missing — Compass accuracy edge cases
- [ ] Add tests for `calculatePositionalAccuracyMeters` with various DOP combinations (PDOP-only, HDOP+VDOP, HDOP-only, precision-bits-only, and none)
- [ ] Add test for `calculateAngularError` when distance is zero
- [x] Add tests for `calculatePositionalAccuracyMeters` with various DOP combinations (PDOP-only, HDOP+VDOP, HDOP-only, precision-bits-only, and none)
- [x] Add test for `calculateAngularError` when distance is zero
- **Rationale**: `CompassViewModelTest` exists but accuracy calculation branch coverage is not verified.
- **Priority**: Medium
### NDM-T103: Missing — Environment NaN guard tests
- [ ] Add tests verifying that `NaN` temperature, humidity, and pressure values are correctly filtered (not rendered, not charted)
- [x] Add tests verifying that `NaN` temperature, humidity, and pressure values are correctly filtered (not rendered, not charted)
- **Rationale**: The code has `isNaN()` guards but no tests validate them.
- **Priority**: Low
### NDM-T104: Missing — Remote admin session timeout testing
- [ ] Add `NodeDetailViewModelTest` coverage for `openRemoteAdmin` with `Disconnected` and `Timeout` session results
- [x] Add `NodeDetailViewModelTest` coverage for `openRemoteAdmin` with `Disconnected` and `Timeout` session results
- **Rationale**: Only `Mute` and `TraceRoute` actions are tested; session error paths are untested.
- **Priority**: Medium
### NDM-T105: Missing — Adaptive layout breakpoint test
- [ ] Add UI test or screenshot test verifying `AdaptiveMetricLayout` switches from Column to Row at 600dp
- [ ] **[DEFERRED]** Add UI test or screenshot test verifying `AdaptiveMetricLayout` switches from Column to Row at 600dp*Deferred: requires Compose UI test infrastructure for adaptive layout breakpoints.*
- **Rationale**: Responsive layout is untested.
- **Priority**: Low
@@ -335,6 +335,6 @@
| Compass | 2 | 2 | 0 |
| Navigation | 1 | 1 | 0 |
| Testing | 10 | 10 | 0 |
| **Gaps** | 6 | 0 | **6** |
| **Total** | **46** | **40** | **6** |
| **Gaps** | 6 | 3 | **3** |
| **Total** | **46** | **43** | **3** |

View File

@@ -312,35 +312,35 @@
## Phase 10 — Gap Tasks (Not Yet Implemented)
- [ ] **SET-T069**: Add Compose UI tests for `RadioConfigItemList` composable — verify section rendering, managed device message display, enabled/disabled state based on connection
- [ ] **[DEFERRED]** **SET-T069**: Add Compose UI tests for `RadioConfigItemList` composable — verify section rendering, managed device message display, enabled/disabled state based on connection*Deferred: requires Compose UI test infrastructure.*
- Target: `commonTest/radio/RadioConfigItemListTest.kt`
- Gap: No UI test coverage for the main radio config list
- [ ] **SET-T070**: Add Compose UI tests for `AdministrationScreen` — verify all admin route items render, confirmation dialogs appear on click, metadata-aware shutdown guard UX
- [ ] **[DEFERRED]** **SET-T070**: Add Compose UI tests for `AdministrationScreen` — verify all admin route items render, confirmation dialogs appear on click, metadata-aware shutdown guard UX*Deferred: requires Compose UI test infrastructure.*
- Target: `commonTest/AdministrationScreenTest.kt`
- Gap: No UI test for admin screen composable
- [ ] **SET-T071**: Add Compose UI tests for `FilterSettingsScreen` — verify filter enable toggle, word add/remove flow, regex indicator display
- [ ] **[DEFERRED]** **SET-T071**: Add Compose UI tests for `FilterSettingsScreen` — verify filter enable toggle, word add/remove flow, regex indicator display*Deferred: requires Compose UI test infrastructure.*
- Target: `commonTest/filter/FilterSettingsScreenTest.kt`
- Gap: Only ViewModel is tested, not the composable
- [ ] **SET-T072**: Add Compose UI tests for `CleanNodeDatabaseScreen` — verify slider interaction, preview list, confirm deletion flow
- [ ] **[DEFERRED]** **SET-T072**: Add Compose UI tests for `CleanNodeDatabaseScreen` — verify slider interaction, preview list, confirm deletion flow*Deferred: requires Compose UI test infrastructure.*
- Target: `commonTest/radio/CleanNodeDatabaseScreenTest.kt`
- Gap: Only ViewModel is tested, not the composable
- [ ] **SET-T073**: Add integration test for profile import → export round-trip verifying `DeviceProfile` protobuf fidelity
- [x] **SET-T073**: Add integration test for profile import → export round-trip verifying `DeviceProfile` protobuf fidelity
- Target: `commonTest/radio/ProfileRoundTripTest.kt`
- Gap: Import and export are tested individually but not end-to-end
- [ ] **SET-T074**: Add test for MQTT probe timeout and error path (`probeMqttConnection` exception handling, `clearMqttProbeStatus`)
- [x] **SET-T074**: Add test for MQTT probe timeout and error path (`probeMqttConnection` exception handling, `clearMqttProbeStatus`)
- Target: `commonTest/radio/RadioConfigViewModelTest.kt` (extend)
- Gap: MQTT probe not tested
- [ ] **SET-T075**: Add accessibility tests — verify TalkBack semantics, touch target sizes, and color-independent information for admin action error colors
- [ ] **[DEFERRED]** **SET-T075**: Add accessibility tests — verify TalkBack semantics, touch target sizes, and color-independent information for admin action error colors*Deferred: requires accessibility testing infrastructure (TalkBack, touch target verification).*
- Target: `commonTest/AdministrationAccessibilityTest.kt`
- Gap: No accessibility testing exists
- [ ] **SET-T076**: Add test for `SettingsViewModel.saveDataCsv()` verifying CSV export through `FileService` and `ExportDataUseCase`
- [x] **SET-T076**: Add test for `SettingsViewModel.saveDataCsv()` verifying CSV export through `FileService` and `ExportDataUseCase`
- Target: `commonTest/SettingsViewModelTest.kt` (extend)
- Gap: CSV export function exists but is not tested

View File

@@ -87,8 +87,8 @@
**Purpose**: Address identified coverage gaps in the existing implementation.
- [ ] MAP-T022 [US3] **[GAP]** Add unit tests for waypoint expiration filtering logic in `BaseMapViewModel` — test that waypoints with `expire > nowSeconds` are included, `expire <= nowSeconds` are excluded, and `expire == 0` (never expires) are always included. File: `feature/map/src/commonTest/.../BaseMapViewModelTest.kt`. (SC-003)
- [ ] MAP-T023 [US1,US5] **[GAP]** Add Compose UI tests for `MapControlsOverlay` and `MapButton` composables — verify compass rotation, filter button click, location tracking toggle icon switch, refresh spinner visibility. File: `feature/map/src/commonTest/.../component/MapControlsOverlayTest.kt`. (NFR-001)
- [x] MAP-T022 [US3] **[GAP]** Add unit tests for waypoint expiration filtering logic in `BaseMapViewModel` — test that waypoints with `expire > nowSeconds` are included, `expire <= nowSeconds` are excluded, and `expire == 0` (never expires) are always included. File: `feature/map/src/commonTest/.../BaseMapViewModelTest.kt`. (SC-003)
- [ ] **[DEFERRED]** MAP-T023 [US1,US5] **[GAP]** Add Compose UI tests for `MapControlsOverlay` and `MapButton` composables — verify compass rotation, filter button click, location tracking toggle icon switch, refresh spinner visibility. File: `feature/map/src/commonTest/.../component/MapControlsOverlayTest.kt`. (NFR-001)*Deferred: requires Compose UI test infrastructure.*
**Dependencies**: Phase 4 testing infrastructure.
**Checkpoint**: Full test coverage achieved.

View File

@@ -81,17 +81,21 @@
## Gaps — Uncompleted Tasks
- [ ] **OB-T100**: Extract hardcoded notification channel ID `"my_alerts"` to a shared constant or resource
- [x] **OB-T100**: Extract hardcoded notification channel ID `"my_alerts"` to a shared constant or resource
- File: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt` (line 112)
- Rationale: Hardcoded string is fragile; should reference the same constant used when the channel is created.
- [ ] **OB-T101**: Migrate UI screens from `androidMain` to `commonMain` using CMP-compatible permission abstraction
- Files: All 8 `androidMain` UI files
- Rationale: Constitution §I requires business logic in `commonMain`. While UI screens are *not* business logic, migrating them enables Desktop/iOS compilation. Requires replacing Accompanist Permissions with a KMP-compatible permission API (e.g., interface + DI expect/actual).
- [x] **OB-T101**: Migrate UI screens from `androidMain` to `commonMain` using CMP-compatible permission abstraction
- Created `IntroPermissions` and `IntroSettingsNavigator` abstractions in `commonMain`
- Moved all 8 UI files (screens, nav graph, helpers) to `commonMain`
- Added `AndroidIntroPermissions`/`AndroidIntroSettingsNavigator` adapters in `androidMain` (wrapping Accompanist)
- Added JVM stubs (`JvmIntroDefaults.kt`) with always-granted permissions
- `AppIntroductionScreen` remains in `androidMain` as thin CompositionLocal provider host
- Added CMP `@PreviewLightDark` previews for all 5 screens
- [ ] **OB-T102**: Add Compose UI tests (screenshot or interaction tests) for all 5 screens
- [ ] **[DEFERRED]** **OB-T102**: Add Compose UI tests (screenshot or interaction tests) for all 5 screens*Deferred: requires Compose UI test infrastructure.*
- Rationale: Only ViewModel logic is unit-tested. No UI rendering or interaction tests exist. Consider `@Preview` screenshot tests or Compose test rule tests.
- [ ] **OB-T103**: Add accessibility verification — ensure all icons have content descriptions, touch targets ≥ 48dp, and TalkBack announces screen transitions
- [ ] **[DEFERRED]** **OB-T103**: Add accessibility verification — ensure all icons have content descriptions, touch targets ≥ 48dp, and TalkBack announces screen transitions*Deferred: requires accessibility testing infrastructure.*
- Rationale: Design Standards Compliance (Constitution §V) requires accessibility review. `FeatureRow` icons use `contentDescription` but no formal audit has been done.

View File

@@ -187,19 +187,19 @@
## Identified Gaps (not yet implemented)
### WFP-T023: Expose hidden network provisioning in UI
- [ ] Add a "Hidden Network" toggle or option in `ConnectedContent` that sets `hidden = true` when calling `provisionWifi`
- [ ] Domain layer already supports `CMD_CONNECT_HIDDEN` (2) — only UI wiring needed
- [x] Add a "Hidden Network" toggle or option in `ConnectedContent` that sets `hidden = true` when calling `provisionWifi`
- [ ] **[DEFERRED]** Domain layer already supports `CMD_CONNECT_HIDDEN` (2) — only UI wiring needed*Deferred: already implemented in WFP-T023; this sub-task is redundant.*
- **Priority**: Low — niche use case
### WFP-T024: Add retry mechanism for BLE scan timeout
- [ ] When BLE scan times out (10s), offer a "Retry" button instead of requiring the user to navigate back and re-enter
- [ ] Consider exponential backoff or a manual retry count limit
- [x] When BLE scan times out (10s), offer a "Retry" button instead of requiring the user to navigate back and re-enter
- [ ] **[DEFERRED]** Consider exponential backoff or a manual retry count limit*Deferred: enhancement — current retry UX is sufficient for v1.*
- **Priority**: Medium — improves UX for unreliable BLE environments
### WFP-T025: Add Compose UI tests
- [ ] Add `@Test` composable tests for `WifiProvisionScreen` phase transitions (ConnectingBle → DeviceFound → Connected)
- [ ] Add interaction tests for network selection, SSID/password input, Apply button enable/disable
- [ ] Add snapshot or screenshot tests for `ProvisionStatusCard` states
- [ ] **[DEFERRED]** Add `@Test` composable tests for `WifiProvisionScreen` phase transitions (ConnectingBle → DeviceFound → Connected)*Deferred: requires Compose UI test infrastructure.*
- [ ] **[DEFERRED]** Add interaction tests for network selection, SSID/password input, Apply button enable/disable*Deferred: requires Compose UI test infrastructure.*
- [ ] **[DEFERRED]** Add snapshot or screenshot tests for `ProvisionStatusCard` states*Deferred: requires Compose UI test infrastructure.*
- **Priority**: Medium — domain and ViewModel well-tested, but UI layer lacks automated verification
---

View File

@@ -194,7 +194,7 @@
- Verify reconnect on network recovery.
- **Priority**: Medium
### DAT-T028: Add MeshRouterImpl unit tests [ ]
### DAT-T028: Add MeshRouterImpl unit tests [x]
- **File to create**: `commonTest/.../manager/MeshRouterImplTest.kt`
- Cover service action routing: send message, request position, traceroute, admin commands.

View File

@@ -149,7 +149,7 @@
- Verify state transitions, profile access, disconnect handling.
- **Priority**: Medium
### BLE-T020: Add KableBleScanner unit tests [ ]
### BLE-T020: Add KableBleScanner unit tests [x]
- **File to create**: `commonTest/.../KableBleConnectionTest.kt`
- Test scan flow emissions, timeout behavior, service UUID filtering.

View File

@@ -179,13 +179,13 @@
- Test USB attach/detach event handling; serial parameter configuration.
- **Priority**: Medium
### NET-T024: Expand MQTT test coverage [ ]
### NET-T024: Expand MQTT test coverage [x]
- **File to extend**: `commonTest/.../MQTTRepositoryImplTest.kt`
- Add tests: topic pattern construction, JSON decode, protobuf decode, reconnect, subscription failure.
- **Priority**: Medium
### NET-T025: Add HeartbeatSender unit test [ ]
### NET-T025: Add HeartbeatSender unit test [x]
- **File to create**: `commonTest/.../HeartbeatSenderTest.kt`
- Test periodic interval, cancellation, edge cases.

View File

@@ -131,19 +131,19 @@
## Gap Tasks (Incomplete)
### DB-T017: Add Converters round-trip tests [ ]
### DB-T017: Add Converters round-trip tests [x]
- **File to create**: `commonTest/.../ConvertersTest.kt`
- Test proto ↔ ByteArray, ByteString ↔ ByteArray round-trips for all converter methods.
- **Priority**: Low
### DB-T018: Add missing DAO tests [ ]
### DB-T018: Add missing DAO tests [x]
- **Files to create**: `commonTest/.../dao/CommonQuickChatActionDaoTest.kt`, `CommonMeshLogDaoTest.kt`, etc.
- Cover CRUD + reactive query behavior for untested DAOs.
- **Priority**: Medium
### DB-T019: Add withDb() concurrent retry test [ ]
### DB-T019: Add withDb() concurrent retry test [x]
- **File to create**: `commonTest/.../DatabaseManagerRetryTest.kt`
- Simulate DB switch during active `withDb()` query; verify retry succeeds.

View File

@@ -153,13 +153,13 @@
## Gap Tasks (Incomplete)
### SVC-T021: Add ServiceRepositoryImpl unit tests [ ]
### SVC-T021: Add ServiceRepositoryImpl unit tests [x]
- **File to create**: `commonTest/.../ServiceRepositoryImplTest.kt`
- Test all state flow emissions: connection, errors, packets, actions, traceroute.
- **Priority**: Medium
### SVC-T022: Add DirectRadioControllerImpl tests [ ]
### SVC-T022: Add DirectRadioControllerImpl tests [x]
- **File to create**: `commonTest/.../DirectRadioControllerImplTest.kt`
- Test direct radio control operations (send, request config, disconnect).

View File

@@ -132,13 +132,13 @@
## Gap Tasks (Incomplete)
### MDL-T018: Add Node domain model unit tests [ ]
### MDL-T018: Add Node domain model unit tests [x]
- **File to create**: `commonTest/.../NodeTest.kt`
- Test `isOnline` boundary values, `distance()` with known coordinates, `bearing()` cardinal directions, `colors` contrast, `createFallback()`, `getRelayNode()`.
- **Priority**: Medium
### MDL-T019: Add MeshDataMapper tests [ ]
### MDL-T019: Add MeshDataMapper tests [x]
- **File to create**: `commonTest/.../util/MeshDataMapperTest.kt`
- Test proto → domain mapping for User, Position, DeviceMetrics, EnvironmentMetrics.
@@ -156,7 +156,7 @@
- Metric ↔ imperial conversion, distance formatting for known values.
- **Priority**: Low
### MDL-T022: Add DataPacket + Message tests [ ]
### MDL-T022: Add DataPacket + Message tests [x]
- **File to create**: `commonTest/.../DataPacketTest.kt`
- Test `nodeNumToDefaultId`, equality, display formatting.