mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-14 01:36:09 -04:00
fix: brownfield gap remediation batch 4-6
Round 2 of brownfield gap remediation across specs 004-017: Tests added: - EnvironmentMetricsForGraphingTest: NaN guard edge cases (NDM-T103) - FormatBytesTest: boundary conditions for formatBytes (NDM-T101) - HostMetricsTest: chart data transformation with extracted pure function - ProfileRoundTripTest: import/export round-trip for radio config (SET-T073) Features implemented: - WiFi provisioning hidden network toggle (WFP-T023) - BLE scan error display + retry button (WFP-T024) - NotificationChannels made public for cross-module use (OB-T100) Code quality: - Extract buildHostMetricsChartData() pure function for testability - Add wifi_provision_hidden_network string resource - Clean up WifiProvisionScreen imports (Switch, retry, hidden network) - Fix WifiProvisionPreviews for updated onProvision signature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,17 @@
|
||||
# Do NOT edit or remove previous entries — stale state claims cause agent confusion.
|
||||
# Format: ## YYYY-MM-DD — <summary>
|
||||
|
||||
## 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.
|
||||
|
||||
1
.skills/compose-ui/strings-index.txt
generated
1
.skills/compose-ui/strings-index.txt
generated
@@ -1270,6 +1270,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
|
||||
|
||||
@@ -1315,6 +1315,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"
|
||||
|
||||
@@ -37,5 +37,6 @@ kotlin {
|
||||
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
}
|
||||
androidMain.dependencies { implementation(projects.core.service) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import org.meshtastic.core.service.NotificationChannels
|
||||
|
||||
/**
|
||||
* Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
|
||||
@@ -108,7 +109,7 @@ internal fun introNavGraph(
|
||||
val intent =
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
|
||||
putExtra(Settings.EXTRA_CHANNEL_ID, NotificationChannels.ALERTS)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
onDone()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user