mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-11 16:15:24 -04:00
Brownfield gap remediation: 28 tasks + intro commonMain migration (#5401)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
4
.skills/compose-ui/strings-index.txt
generated
4
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 & Forward Config</string>
|
||||
<string name="store_forward_enabled">Store & 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -37,5 +37,6 @@ kotlin {
|
||||
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
}
|
||||
androidMain.dependencies { implementation(projects.core.service) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}) } }
|
||||
}
|
||||
@@ -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 = {}) } }
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
@@ -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") }
|
||||
@@ -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") }
|
||||
@@ -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,
|
||||
@@ -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 = {}) } }
|
||||
}
|
||||
@@ -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 = {}) }
|
||||
}
|
||||
}
|
||||
@@ -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 = {}) } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 468–470)
|
||||
- **Issue**: `contentDescription` for mute/unmute icons uses hardcoded `"Mute selected"` / `"Unmute selected"` instead of `stringResource()`.
|
||||
|
||||
@@ -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.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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** |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user