diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index c171393c3..bed355d44 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -3,6 +3,17 @@ # Do NOT edit or remove previous entries — stale state claims cause agent confusion. # Format: ## YYYY-MM-DD — +## 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. diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 4e1d3e56e..f7216d831 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index c26f38bca..cf771f4d7 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1315,6 +1315,7 @@ Provision Wi-Fi credentials to your mPWRD-OS device via Bluetooth. Device found Ready to scan for WiFi networks. + Hidden network Learn more about the mPWRD-OS project\nhttps://github.com/mPWRD-OS No networks found Failed to scan for WiFi networks: %1$s diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt index f8db3a517..8e692d769 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/NotificationChannels.kt @@ -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" diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts index 60bbdf6dd..a55154d6a 100644 --- a/feature/intro/build.gradle.kts +++ b/feature/intro/build.gradle.kts @@ -37,5 +37,6 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) } + androidMain.dependencies { implementation(projects.core.service) } } } diff --git a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt index e60fa4441..78da0ac06 100644 --- a/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt +++ b/feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt @@ -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() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt index 87c614489..dcc91ad2a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt @@ -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 = emptyList(), + val load5: List = emptyList(), + val load15: List = emptyList(), + val freeMemoryMb: List = emptyList(), +) { + val hasLoad: Boolean + get() = load1.isNotEmpty() || load5.isNotEmpty() || load15.isNotEmpty() +} + +internal fun buildHostMetricsChartData(data: List): 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 diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt index 10cdb42d5..f3185f226 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt @@ -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 diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt index aaa0d8631..397b745c6 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt @@ -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)) + } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt new file mode 100644 index 000000000..4b769f157 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt @@ -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 . + */ +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()) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt new file mode 100644 index 000000000..b01a9cad7 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt @@ -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 . + */ +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() + 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() + + 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") + } +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt index f418e57ef..631434412 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt @@ -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 { diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt index fef0f0e7b..d74d1f615 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt @@ -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 diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt index e24941c0d..e824ddadf 100644 --- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -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), ) {