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:
James Rich
2026-05-11 13:46:38 -05:00
parent 14d7cf4ae0
commit af2854a8d3
14 changed files with 530 additions and 37 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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