diff --git a/AGENTS.md b/AGENTS.md index b68b7a3b8..501a5c3c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core/ble/` | Bluetooth Low Energy stack using Kable. | | `core/resources/` | Centralized string and image resources (Compose Multiplatform). | | `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. | +| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Scans for provisioning devices, lists available networks, applies credentials. Uses `core:ble` Kable abstractions. | | `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. | | `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | | `mesh_service_example/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. | @@ -61,6 +62,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec ### A. UI Development (Jetpack Compose) - **Material 3:** The app uses Material 3. - **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine. +- **String formatting:** CMP's `stringResource(res, args)` / `getString(res, args)` only support `%N$s` (string) and `%N$d` (integer) positional specifiers. Float formats like `%N$.1f` are NOT supported — they pass through unsubstituted. For float values, pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass the result as a `%N$s` string arg. Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings, since CMP does not convert `%%` to `%`. For JVM-only code using `formatString()` (which wraps `String.format()`), full printf specifiers including `%N$.Nf` and `%%` are supported. - **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`). - **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable. - **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules. diff --git a/app/README.md b/app/README.md index 7f8f354ef..ff6f5542f 100644 --- a/app/README.md +++ b/app/README.md @@ -50,6 +50,7 @@ graph TB :app -.-> :feature:node :app -.-> :feature:settings :app -.-> :feature:firmware + :app -.-> :feature:wifi-provision :app -.-> :feature:widget classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 074b58df5..77a543964 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -236,6 +236,7 @@ dependencies { implementation(projects.feature.node) implementation(projects.feature.settings) implementation(projects.feature.firmware) + implementation(projects.feature.wifiProvision) implementation(projects.feature.widget) implementation(libs.jetbrains.compose.material3.adaptive) diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt index d58894824..09f38eaef 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt @@ -54,6 +54,7 @@ import org.meshtastic.feature.messaging.di.FeatureMessagingModule import org.meshtastic.feature.node.di.FeatureNodeModule import org.meshtastic.feature.settings.di.FeatureSettingsModule import org.meshtastic.feature.widget.di.FeatureWidgetModule +import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule @Module( includes = @@ -87,6 +88,7 @@ import org.meshtastic.feature.widget.di.FeatureWidgetModule FeatureFirmwareModule::class, FeatureIntroModule::class, FeatureWidgetModule::class, + FeatureWifiProvisionModule::class, NetworkModule::class, FlavorModule::class, ], diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 4cad8493c..19c6c9ddf 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -48,6 +48,7 @@ import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph import org.meshtastic.feature.settings.navigation.settingsGraph import org.meshtastic.feature.settings.radio.channel.channelsGraph +import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @Composable fun MainScreen() { @@ -76,6 +77,7 @@ fun MainScreen() { connectionsGraph(backStack) settingsGraph(backStack) firmwareGraph(backStack) + wifiProvisionGraph(backStack) } MeshtasticNavDisplay( multiBackstack = multiBackstack, diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt new file mode 100644 index 000000000..94b81f0fb --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt @@ -0,0 +1,96 @@ +/* + * 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.core.common.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FormatStringTest { + + @Test + fun positionalStringSubstitution() { + assertEquals("Hello World", formatString("%1\$s %2\$s", "Hello", "World")) + } + + @Test + fun positionalIntSubstitution() { + assertEquals("Count: 42", formatString("Count: %1\$d", 42)) + } + + @Test + fun positionalFloatSubstitution() { + assertEquals("Value: 3.1", formatString("Value: %1\$.1f", 3.14159)) + } + + @Test + fun positionalFloatTwoDecimals() { + assertEquals("12.35%", formatString("%1\$.2f%%", 12.345)) + } + + @Test + fun literalPercentEscape() { + assertEquals("100%", formatString("100%%")) + } + + @Test + fun mixedPositionalArgs() { + assertEquals("Battery: 85, Voltage: 3.7 V", formatString("Battery: %1\$d, Voltage: %2\$.1f V", 85, 3.7)) + } + + @Test + fun deviceMetricsPercentTemplate() { + assertEquals("ChUtil: 18.5%", formatString("%1\$s: %2\$.1f%%", "ChUtil", 18.456)) + } + + @Test + fun deviceMetricsVoltageTemplate() { + assertEquals("Voltage: 3.7 V", formatString("%1\$s: %2\$.1f V", "Voltage", 3.725)) + } + + @Test + fun deviceMetricsNumericTemplate() { + assertEquals("42.3", formatString("%1\$.1f", 42.345)) + } + + @Test + fun localStatsUtilizationTemplate() { + assertEquals( + "ChUtil: 12.35% | AirTX: 5.68%", + formatString("ChUtil: %1\$.2f%% | AirTX: %2\$.2f%%", 12.345, 5.678), + ) + } + + @Test + fun noArgsPlainString() { + assertEquals("Hello", formatString("Hello")) + } + + @Test + fun sequentialStringSubstitution() { + assertEquals("a b", formatString("%s %s", "a", "b")) + } + + @Test + fun sequentialIntSubstitution() { + assertEquals("1 2", formatString("%d %d", 1, 2)) + } + + @Test + fun sequentialFloatSubstitution() { + assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45)) + } +} diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt index 6d1acf46f..1362de98b 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt @@ -16,7 +16,85 @@ */ package org.meshtastic.core.common.util -/** Apple (iOS) implementation of string formatting. Stub implementation for compile-only validation. */ -actual fun formatString(pattern: String, vararg args: Any?): String = throw UnsupportedOperationException( - "formatString is not supported on iOS at runtime; this target is intended for compile-only validation.", -) +/** + * Apple (iOS) implementation of string formatting. + * + * Implements a subset of Java's `String.format()` patterns used in this codebase: + * - `%s`, `%d` — positional or sequential string/integer + * - `%N$s`, `%N$d` — explicit positional string/integer + * - `%N$.Nf`, `%.Nf` — float with decimal precision + * - `%%` — literal percent + * + * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions). + */ +actual fun formatString(pattern: String, vararg args: Any?): String = buildString { + var i = 0 + var autoIndex = 0 + while (i < pattern.length) { + if (pattern[i] != '%') { + append(pattern[i]) + i++ + continue + } + i++ // skip '%' + if (i >= pattern.length) break + + // Literal %% + if (pattern[i] == '%') { + append('%') + i++ + continue + } + + // Parse optional positional index (N$) + var explicitIndex: Int? = null + val startPos = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i < pattern.length && pattern[i] == '$' && i > startPos) { + explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed + i++ // skip '$' + } else { + i = startPos // rewind — digits are part of width/precision, not positional index + } + + // Parse optional flags/width (skip for now — not used in this codebase) + + // Parse optional precision (.N) + var precision: Int? = null + if (i < pattern.length && pattern[i] == '.') { + i++ // skip '.' + val precStart = i + while (i < pattern.length && pattern[i].isDigit()) i++ + if (i > precStart) { + precision = pattern.substring(precStart, i).toInt() + } + } + + // Parse conversion character + if (i >= pattern.length) break + val conversion = pattern[i] + i++ + + val argIndex = explicitIndex ?: autoIndex++ + val arg = args.getOrNull(argIndex) + + when (conversion) { + 's' -> append(arg?.toString() ?: "null") + 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0") + 'f' -> { + val value = (arg as? Number)?.toDouble() ?: 0.0 + val places = precision ?: DEFAULT_FLOAT_PRECISION + append(NumberFormatter.format(value, places)) + } + else -> { + // Unknown conversion — reproduce original token + append('%') + if (explicitIndex != null) append("${explicitIndex + 1}$") + if (precision != null) append(".$precision") + append(conversion) + } + } + } +} + +private const val DEFAULT_FLOAT_PRECISION = 6 diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt index 23deaf6aa..12f5a911c 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -35,7 +35,8 @@ import org.meshtastic.core.common.util.CommonUri * - `/messages/{contactKey}` -> Specific conversation * - `/settings` -> Settings root * - `/settings/{destNum}/{page}` -> Specific settings page for a node - * - `/share?message={text}` -> Share message screen + * - `/wifi-provision` -> WiFi provisioning screen + * - `/wifi-provision?address={mac}` -> WiFi provisioning targeting a specific device MAC address */ object DeepLinkRouter { /** @@ -64,6 +65,7 @@ object DeepLinkRouter { "settings" -> routeSettings(pathSegments) "channels" -> listOf(ChannelsRoutes.ChannelsGraph) "firmware" -> routeFirmware(pathSegments) + "wifi-provision" -> routeWifiProvision(uri) else -> { Logger.w { "Unrecognized deep link segment: $firstSegment" } null @@ -151,6 +153,11 @@ object DeepLinkRouter { } } + private fun routeWifiProvision(uri: CommonUri): List { + val address = uri.getQueryParameter("address") + return listOf(WifiProvisionRoutes.WifiProvision(address)) + } + private fun routeFirmware(segments: List): List { val update = if (segments.size > 1) segments[1].lowercase() == "update" else false return if (update) { diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 0bcbf1b27..b58b20f2b 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -176,3 +176,9 @@ object FirmwareRoutes { @Serializable data object FirmwareUpdate : Route } + +object WifiProvisionRoutes { + @Serializable data object WifiProvisionGraph : Graph + + @Serializable data class WifiProvision(val address: String? = null) : Route +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 1527a698b..692ddcb37 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -29,7 +29,7 @@ mqtt.meshtastic.org - Meshtastic + Meshtastic %1$s Filter clear node filter Filter by @@ -186,7 +186,6 @@ Debug MSL - ChUtil %.1f%% AirUtilTX %.1f%% Ch Channel Name @@ -405,8 +404,8 @@ Currently: Always muted Not muted - Muted for %1$d days, %2$.1f hours - Muted for %1$.1f hours + Muted for %1$d days, %2$s hours + Muted for %1$s hours Mute status Mute notifications for '%1$s'? Unmute notifications for '%1$s'? @@ -504,7 +503,7 @@ Are you sure? Device Role Documentation and the blog post about Choosing The Right Device Role.]]> I know what I'm doing. - Node %1$s has a low battery (%2$d%%) + Node %1$s has a low battery (%2$d%) Low battery notifications Low battery: %1$s Low battery notifications (favorite nodes) @@ -1081,7 +1080,7 @@ Stable Alpha Note: This will temporarily disconnect your device during the update. - Downloading firmware... %1$d%% + Downloading firmware... %1$d% Error: %1$s Retry Update Successful! @@ -1132,7 +1131,7 @@ DFU Error: %1$s DFU Aborted Node user information is missing. - Battery too low (%1$d%%). Please charge your device before updating. + Battery too low (%1$d%). Please charge your device before updating. Could not retrieve firmware file. Nordic DFU Update failed USB Update failed @@ -1144,7 +1143,7 @@ Checking device version... Starting OTA update... Uploading firmware... - Uploading firmware... %1$d%% (%2$s) + Uploading firmware... %1$d% (%2$s) Rebooting device... Firmware Update Firmware update status @@ -1230,10 +1229,10 @@ Map style selection - Battery: %1$d%% + Battery: %1$d% Nodes: %1$d online / %2$d total Uptime: %1$s - ChUtil: %1$.2f%% | AirTX: %2$.2f%% + ChUtil: %1$s% | AirTX: %2$s% Traffic: TX %1$d / RX %2$d (D: %3$d) Relays: %1$d (Canceled: %2$d) Diagnostics: %1$s @@ -1325,4 +1324,28 @@ Files available (%1$d): - %1$s (%2$d bytes) No files manifested. + + Connect + Done + WiFi Provisioning + Provision WiFi credentials to your Meshtastic device via Bluetooth. + Searching for device… + Device found + Ready to scan for WiFi networks. + Scan for Networks + Scanning… + Applying WiFi configuration… + WiFi configured successfully! + WiFi credentials applied. The device will connect to the network shortly. + No networks found + Make sure the device is powered on and within range. + Could not connect: %1$s + Failed to scan for WiFi networks: %1$s + Refresh + %1$d% + Available Networks + Network Name (SSID) + Enter or select a network + WiFi configured successfully! + Failed to apply WiFi configuration diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index e5468eb66..095010440 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message @@ -667,7 +668,7 @@ class MeshServiceNotificationsImpl( } private fun createNewNodeSeenNotification(name: String, message: String, nodeNum: Int): Notification { - val title = getString(Res.string.new_node_seen).format(name) + val title = getString(Res.string.new_node_seen, name) val builder = commonBuilder(NotificationType.NewNode, createOpenNodeDetailIntent(nodeNum)) .setCategory(Notification.CATEGORY_STATUS) @@ -683,9 +684,9 @@ class MeshServiceNotificationsImpl( private fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification { val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal - val title = getString(Res.string.low_battery_title).format(node.user.short_name) + val title = getString(Res.string.low_battery_title, node.user.short_name) val batteryLevel = node.deviceMetrics.battery_level ?: 0 - val message = getString(Res.string.low_battery_message).format(node.user.long_name, batteryLevel) + val message = getString(Res.string.low_battery_message, node.user.long_name, batteryLevel) return commonBuilder(type, createOpenNodeDetailIntent(node.num)) .setCategory(Notification.CATEGORY_STATUS) @@ -876,44 +877,48 @@ class MeshServiceNotificationsImpl( if (it > MAX_BATTERY_LEVEL) { parts.add(BULLET + getString(Res.string.powered)) } else { - parts.add(BULLET + getString(Res.string.local_stats_battery).format(it)) + parts.add(BULLET + getString(Res.string.local_stats_battery, it)) } } - parts.add(BULLET + getString(Res.string.local_stats_nodes).format(num_online_nodes, num_total_nodes)) - parts.add(BULLET + getString(Res.string.local_stats_uptime).format(formatUptime(uptime_seconds))) - parts.add(BULLET + getString(Res.string.local_stats_utilization).format(channel_utilization, air_util_tx)) + parts.add(BULLET + getString(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes)) + parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(uptime_seconds))) + parts.add( + BULLET + + getString( + Res.string.local_stats_utilization, + NumberFormatter.format(channel_utilization.toDouble(), 2), + NumberFormatter.format(air_util_tx.toDouble(), 2), + ), + ) if (heap_free_bytes > 0 || heap_total_bytes > 0) { parts.add( BULLET + getString(Res.string.local_stats_heap) + ": " + - getString(Res.string.local_stats_heap_value).format(heap_free_bytes, heap_total_bytes), + getString(Res.string.local_stats_heap_value, heap_free_bytes, heap_total_bytes), ) } // Traffic Stats if (num_packets_tx > 0 || num_packets_rx > 0) { - parts.add( - BULLET + getString(Res.string.local_stats_traffic).format(num_packets_tx, num_packets_rx, num_rx_dupe), - ) + parts.add(BULLET + getString(Res.string.local_stats_traffic, num_packets_tx, num_packets_rx, num_rx_dupe)) } if (num_tx_relay > 0) { - parts.add(BULLET + getString(Res.string.local_stats_relays).format(num_tx_relay, num_tx_relay_canceled)) + parts.add(BULLET + getString(Res.string.local_stats_relays, num_tx_relay, num_tx_relay_canceled)) } // Diagnostic Fields val diagnosticParts = mutableListOf() - if (noise_floor != 0) diagnosticParts.add(getString(Res.string.local_stats_noise).format(noise_floor)) + if (noise_floor != 0) diagnosticParts.add(getString(Res.string.local_stats_noise, noise_floor)) if (num_packets_rx_bad > 0) { - diagnosticParts.add(getString(Res.string.local_stats_bad).format(num_packets_rx_bad)) + diagnosticParts.add(getString(Res.string.local_stats_bad, num_packets_rx_bad)) } - if (num_tx_dropped > 0) diagnosticParts.add(getString(Res.string.local_stats_dropped).format(num_tx_dropped)) + if (num_tx_dropped > 0) diagnosticParts.add(getString(Res.string.local_stats_dropped, num_tx_dropped)) if (diagnosticParts.isNotEmpty()) { parts.add( - BULLET + - getString(Res.string.local_stats_diagnostics_prefix).format(diagnosticParts.joinToString(" | ")), + BULLET + getString(Res.string.local_stats_diagnostics_prefix, diagnosticParts.joinToString(" | ")), ) } @@ -922,12 +927,16 @@ class MeshServiceNotificationsImpl( private fun DeviceMetrics.formatToString(): String { val parts = mutableListOf() - battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery).format(it)) } - uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime).format(formatUptime(it))) } + battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) } + uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(it))) } if (channel_utilization != null || air_util_tx != null) { parts.add( BULLET + - getString(Res.string.local_stats_utilization).format(channel_utilization ?: 0f, air_util_tx ?: 0f), + getString( + Res.string.local_stats_utilization, + NumberFormatter.format((channel_utilization ?: 0f).toDouble(), 2), + NumberFormatter.format((air_util_tx ?: 0f).toDouble(), 2), + ), ) } return parts.joinToString("\n") diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index ee3f0d50f..58b5e4428 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -177,6 +177,7 @@ dependencies { implementation(projects.feature.connections) implementation(projects.feature.map) implementation(projects.feature.firmware) + implementation(projects.feature.wifiProvision) implementation(projects.feature.intro) // Compose Desktop diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 38e0927f8..b4b47736e 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -73,6 +73,7 @@ import org.meshtastic.feature.map.di.module as featureMapModule import org.meshtastic.feature.messaging.di.module as featureMessagingModule import org.meshtastic.feature.node.di.module as featureNodeModule import org.meshtastic.feature.settings.di.module as featureSettingsModule +import org.meshtastic.feature.wifiprovision.di.module as featureWifiProvisionModule /** * Koin module for the Desktop target. @@ -108,6 +109,7 @@ fun desktopModule() = module { org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(), org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(), org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(), + org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(), org.meshtastic.desktop.di.DesktopDiModule().desktopDiModule(), desktopPlatformStubsModule(), ) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index 0225fc0a0..f30ecb66b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -26,6 +26,7 @@ import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph import org.meshtastic.feature.settings.navigation.settingsGraph import org.meshtastic.feature.settings.radio.channel.channelsGraph +import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph /** * Registers entry providers for all top-level desktop destinations. @@ -63,4 +64,7 @@ fun EntryProviderScope.desktopNavGraph( // Connections — shared screen connectionsGraph(backStack) + + // WiFi Provisioning — nymea-networkmanager BLE protocol + wifiProvisionGraph(backStack) } diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md index ae4682a40..27cd8d5e4 100644 --- a/docs/decisions/architecture-review-2026-03.md +++ b/docs/decisions/architecture-review-2026-03.md @@ -7,14 +7,14 @@ Re-evaluation of project modularity and architecture against modern KMP and Andr ## Executive Summary -The codebase is **~98% structurally KMP** — 18/20 core modules and 7/7 feature modules declare `jvm()` targets and cross-compile in CI. Shared `commonMain` code accounts for ~52K LOC vs ~18K platform-specific LOC (a 74/26 split). This is strong. +The codebase is **~98% structurally KMP** — 18/20 core modules and 8/8 feature modules declare `jvm()` targets and cross-compile in CI. Shared `commonMain` code accounts for ~52K LOC vs ~18K platform-specific LOC (a 74/26 split). This is strong. Of the five structural gaps originally identified, four are resolved and one remains in progress: 1. **`app` is a God module** — originally 90 files / ~11K LOC of transport, service, UI, and ViewModel code that should live in core/feature modules. *(✅ Resolved — app module reduced to 6 files: `MainActivity`, `MeshUtilApplication`, Nav shell, and DI config)* 2. ~~**Radio transport layer is app-locked**~~ — ✅ Resolved: `RadioTransport` interface in `core:repository/commonMain`; shared `StreamFrameCodec` + `TcpTransport` in `core:network`. 3. ~~**`java.*` APIs leak into `commonMain`**~~ — ✅ Resolved: `Locale`, `ConcurrentHashMap`, `ReentrantLock` purged. -4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 131 shared tests across all 7 features; `core:testing` module established. +4. ~~**Zero feature-level `commonTest`**~~ — ✅ Resolved: 193 shared tests across all 8 features; `core:testing` module established. 5. ~~**No `feature:connections` module**~~ — ✅ Resolved: KMP module with shared UI and dynamic transport detection. ## Source Code Distribution @@ -168,10 +168,9 @@ Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModul | `feature:messaging` | 18 | 5 | 3 | | `feature:connections` | 27 | 0 | 0 | | `feature:firmware` | 15 | 25 | 0 | -| `feature:intro` | 14 | 7 | 0 | -| `feature:map` | 11 | 4 | 0 | +| `feature:wifi-provision` | 62 | 0 | 0 | -**Outcome:** All 7 feature modules now have `commonTest` coverage (131 shared tests). Combined with 70 platform unit tests and 18 instrumented tests, feature modules have 219 tests total. +**Outcome:** All 8 feature modules now have `commonTest` coverage (193 shared tests). Combined with 70 platform unit tests and 18 instrumented tests, feature modules have 281 tests total. ### D2. No shared test fixtures *(resolved 2026-03-12)* @@ -217,12 +216,12 @@ Ordered by impact × effort: | Area | Previous | Current | Notes | |---|---:|---:|---| | Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared | -| Shared feature/UI logic | 9.5/10 | **9/10** | All 7 KMP features; connections unified; cross-platform deduplication complete | +| Shared feature/UI logic | 9.5/10 | **9/10** | All 8 KMP features; connections unified; cross-platform deduplication complete | | Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files | | Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | -| CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers | +| CI confidence | 8.5/10 | **9/10** | 26 modules validated; feature:connections + feature:wifi-provision + desktop in CI; native release installers | | DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features | +| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | --- diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 89a33d5c3..8174e4db2 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -40,7 +40,7 @@ Modules that share JVM-specific code between Android and desktop now standardize **19/21** core modules are KMP with JVM targets. The 2 Android-only modules are intentionally platform-specific, with shared contracts already abstracted into `core:ui/commonMain`. -### Feature Modules (8 total — 8 KMP with JVM, 1 Android-only widget) +### Feature Modules (9 total — 9 KMP with JVM, 1 Android-only widget) | Module | UI in commonMain? | Desktop wired? | |---|:---:|:---:| @@ -51,6 +51,7 @@ Modules that share JVM-specific code between Android and desktop now standardize | `feature:intro` | — | — | Screens remain in `androidMain`; shared ViewModel only | | `feature:map` | — | Placeholder; shared `NodeMapViewModel` and `BaseMapViewModel` only | | `feature:firmware` | ✅ | ✅ Fully KMP: Unified OTA, native Secure DFU, USB/UF2, FirmwareRetriever | +| `feature:wifi-provision` | ✅ | ✅ KMP WiFi provisioning via BLE (Nymea protocol); shared UI and ViewModel | | `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | ### Desktop Module @@ -73,12 +74,12 @@ Working Compose Desktop application with: | Area | Score | Notes | |---|---|---| | Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified | -| Shared feature/UI logic | **9/10** | 8 KMP feature modules; firmware fully migrated; `feature:intro` and `feature:map` share ViewModels but UI remains in `androidMain` | +| Shared feature/UI logic | **9/10** | 9 KMP feature modules; firmware fully migrated; wifi-provision added; `feature:intro` and `feature:map` share ViewModels but UI remains in `androidMain` | | Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) | | Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully | -| CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated | +| CI confidence | **9/10** | 26 modules validated (including feature:wifi-provision); native release installers automated | | DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform | -| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features | +| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 9 features | > See [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md) for the full gap analysis. diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index 30a80fad4..6292f9ad9 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -62,6 +62,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.MeshtasticUri +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Contact import org.meshtastic.core.model.ContactSettings @@ -368,10 +369,11 @@ private fun MuteNotificationsDialog( val remaining = settings.muteUntil - now if (remaining > 0) { val (days, hours) = formatMuteRemainingTime(remaining) + val hoursFormatted = NumberFormatter.format(hours, 1) if (days >= 1) { - stringResource(Res.string.mute_status_muted_for_days, days, hours) + stringResource(Res.string.mute_status_muted_for_days, days, hoursFormatted) } else { - stringResource(Res.string.mute_status_muted_for_hours, hours) + stringResource(Res.string.mute_status_muted_for_hours, hoursFormatted) } } else { stringResource(Res.string.mute_status_unmuted) diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 1f759eb5b..6cc890098 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,17 +44,20 @@ import org.meshtastic.core.common.util.toInstant import org.meshtastic.core.common.util.toMeshtasticUri import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.WifiProvisionRoutes import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bottom_nav_settings import org.meshtastic.core.resources.export_configuration import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.remotely_administrating +import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.feature.settings.component.AppInfoSection import org.meshtastic.feature.settings.component.AppearanceSection +import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PersistenceSection import org.meshtastic.feature.settings.component.PrivacySection import org.meshtastic.feature.settings.component.ThemePickerDialog @@ -226,6 +231,12 @@ fun SettingsScreen( onShowThemePicker = { showThemePickerDialog = true }, ) + ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { + ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = Icons.Rounded.Wifi) { + onNavigate(WifiProvisionRoutes.WifiProvision()) + } + } + PersistenceSection( cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value, onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) }, diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 3170a499e..b4b0fdee7 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.rounded.FormatPaint import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Memory +import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,6 +47,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.DatabaseConstants import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes +import org.meshtastic.core.navigation.WifiProvisionRoutes import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.acknowledgements import org.meshtastic.core.resources.app_settings @@ -59,6 +61,7 @@ import org.meshtastic.core.resources.modules_unlocked import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.remotely_administrating import org.meshtastic.core.resources.theme +import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar @@ -197,6 +200,12 @@ fun DesktopSettingsScreen( ) } + ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { + ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = Icons.Rounded.Wifi) { + onNavigate(WifiProvisionRoutes.WifiProvision()) + } + } + NotificationSection( messagesEnabled = settingsViewModel.messagesEnabled.collectAsStateWithLifecycle().value, onToggleMessages = { settingsViewModel.setMessagesEnabled(it) }, diff --git a/feature/wifi-provision/README.md b/feature/wifi-provision/README.md new file mode 100644 index 000000000..4e61464a0 --- /dev/null +++ b/feature/wifi-provision/README.md @@ -0,0 +1,67 @@ +# `:feature:wifi-provision` + +## Module dependency graph + + +```mermaid +graph TB + :feature:wifi-provision[wifi-provision]:::kmp-feature + +classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; +classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; +classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000; +classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; +classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000; +classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000; +classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000; +classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000; +classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000; +classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000; +classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000; +classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000; + +``` + + +## WiFi Provisioning System + +The `:feature:wifi-provision` module provides BLE-based WiFi provisioning for Meshtastic devices using the Nymea network manager protocol. It scans for provisioning-capable devices, retrieves available WiFi networks, and applies credentials — all over BLE via the Kable multiplatform library. + +### Architecture + +- **Protocol:** Nymea BLE network manager (GATT service `e081fec0-f757-4449-b9c9-bfa83133f7fc`) +- **Transport:** BLE via `core:ble` Kable abstractions with chunked packet codec +- **UI:** Single-screen Material 3 Expressive flow with 6 phases (Idle, ConnectingBle, DeviceFound, LoadingNetworks, Connected, Provisioning) + +```mermaid +sequenceDiagram + participant App as Meshtastic App + participant BLE as BLE Scanner + participant Device as Provisioning Device + + Note over App: Phase 1: Scan + App->>BLE: Scan for GATT service UUID + BLE-->>App: Device discovered + + Note over App: Phase 2: Connect + App->>Device: BLE Connect + Device-->>App: Device name (confirmation) + + Note over App, Device: Phase 3: Network List + App->>Device: GetNetworks command + Device-->>App: WiFi networks (deduplicated by SSID) + + Note over App, Device: Phase 4: Provision + App->>Device: Connect(SSID, password) + Device-->>App: NetworkingStatus response + App->>Device: Disconnect BLE +``` + +### Key Classes + +- `WifiProvisionViewModel.kt`: MVI state machine with 6 phases and SSID deduplication. +- `WifiProvisionScreen.kt`: Material 3 Expressive single-screen UI with Crossfade transitions. +- `NymeaWifiService.kt`: BLE service layer — connect, scan networks, provision, close. +- `NymeaPacketCodec.kt`: Chunked BLE packet encoder/decoder with reassembly. +- `NymeaProtocol.kt`: JSON serialization for Nymea network manager commands and responses. +- `ProvisionStatusCard.kt`: Inline status feedback card (idle/success/failed) with Material 3 colors. diff --git a/feature/wifi-provision/build.gradle.kts b/feature/wifi-provision/build.gradle.kts new file mode 100644 index 000000000..4b44b0544 --- /dev/null +++ b/feature/wifi-provision/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * 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 . + */ +plugins { + alias(libs.plugins.meshtastic.kmp.feature) + alias(libs.plugins.meshtastic.kotlinx.serialization) +} + +kotlin { + @Suppress("UnstableApiUsage") + android { + namespace = "org.meshtastic.feature.wifiprovision" + androidResources.enable = false + } + + sourceSets { + commonMain.dependencies { + implementation(projects.core.ble) + implementation(projects.core.common) + implementation(projects.core.navigation) + implementation(projects.core.resources) + implementation(projects.core.ui) + + implementation(libs.jetbrains.navigation3.ui) + implementation(libs.kotlinx.serialization.json) + } + } +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt new file mode 100644 index 000000000..f174d5746 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt @@ -0,0 +1,100 @@ +/* + * 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.wifiprovision + +import kotlin.uuid.Uuid + +/** + * GATT UUIDs for the nymea-networkmanager Bluetooth provisioning profile. + * + * Reference: https://github.com/nymea/nymea-networkmanager#bluetooth-gatt-profile + */ +internal object NymeaBleConstants { + + // region Wireless Service + /** Primary service for WiFi management. */ + val WIRELESS_SERVICE_UUID: Uuid = Uuid.parse("e081fec0-f757-4449-b9c9-bfa83133f7fc") + + /** + * Write JSON commands (chunked into ≤20-byte packets, newline-terminated) to this characteristic. Each command + * generates a response on [COMMANDER_RESPONSE_UUID]. + */ + val WIRELESS_COMMANDER_UUID: Uuid = Uuid.parse("e081fec1-f757-4449-b9c9-bfa83133f7fc") + + /** + * Subscribe (notify) to receive JSON responses. Uses the same 20-byte chunked, newline-terminated framing as the + * commander. + */ + val COMMANDER_RESPONSE_UUID: Uuid = Uuid.parse("e081fec2-f757-4449-b9c9-bfa83133f7fc") + + /** Read/notify: current WiFi adapter connection state (1 byte). */ + val WIRELESS_CONNECTION_STATUS_UUID: Uuid = Uuid.parse("e081fec3-f757-4449-b9c9-bfa83133f7fc") + // endregion + + // region Network Service + + /** Service for enabling/disabling networking and wireless. */ + val NETWORK_SERVICE_UUID: Uuid = Uuid.parse("ef6d6610-b8af-49e0-9eca-ab343513641c") + + /** Read/notify: overall NetworkManager state (1 byte). */ + val NETWORK_STATUS_UUID: Uuid = Uuid.parse("ef6d6611-b8af-49e0-9eca-ab343513641c") + // endregion + + // region Protocol framing + + /** Maximum ATT payload per packet when MTU negotiation is unavailable. */ + const val MAX_PACKET_SIZE = 20 + + /** JSON stream terminator — marks the end of a reassembled message. */ + const val STREAM_TERMINATOR = '\n' + + /** Scan + connect timeout in milliseconds. */ + const val SCAN_TIMEOUT_MS = 10_000L + + /** Maximum time to wait for a command response. */ + const val RESPONSE_TIMEOUT_MS = 15_000L + + /** Settle time after subscribing to notifications before sending commands. */ + const val SUBSCRIPTION_SETTLE_MS = 300L + // endregion + + // region Wireless Commander command codes + + /** Request the list of visible WiFi networks. */ + const val CMD_GET_NETWORKS = 0 + + /** Connect to a network using SSID + password. */ + const val CMD_CONNECT = 1 + + /** Connect to a hidden network using SSID + password. */ + const val CMD_CONNECT_HIDDEN = 2 + + /** Trigger a fresh WiFi scan. */ + const val CMD_SCAN = 4 + // endregion + + // region Response error codes + const val RESPONSE_SUCCESS = 0 + const val RESPONSE_INVALID_COMMAND = 1 + const val RESPONSE_INVALID_PARAMETER = 2 + const val RESPONSE_NETWORK_MANAGER_UNAVAILABLE = 3 + const val RESPONSE_WIRELESS_UNAVAILABLE = 4 + const val RESPONSE_NETWORKING_DISABLED = 5 + const val RESPONSE_WIRELESS_DISABLED = 6 + const val RESPONSE_UNKNOWN = 7 + // endregion +} 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 new file mode 100644 index 000000000..af0541d43 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt @@ -0,0 +1,257 @@ +/* + * 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.wifiprovision + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.Factory +import org.meshtastic.core.ble.BleConnectionFactory +import org.meshtastic.core.ble.BleScanner +import org.meshtastic.feature.wifiprovision.domain.NymeaWifiService +import org.meshtastic.feature.wifiprovision.model.ProvisionResult +import org.meshtastic.feature.wifiprovision.model.WifiNetwork + +// --------------------------------------------------------------------------- +// UI State +// --------------------------------------------------------------------------- + +data class WifiProvisionUiState( + val phase: Phase = Phase.Idle, + val networks: List = emptyList(), + val error: WifiProvisionError? = null, + /** Name of the BLE device we connected to, shown in the DeviceFound confirmation. */ + val deviceName: String? = null, + /** Provisioning outcome shown as inline status (matches web flasher pattern). */ + val provisionStatus: ProvisionStatus = ProvisionStatus.Idle, +) { + enum class Phase { + /** No operation running — initial state before BLE connect. */ + Idle, + + /** Scanning BLE for a nymea device. */ + ConnectingBle, + + /** BLE device found and connected; waiting for user to proceed. */ + DeviceFound, + + /** Fetching visible WiFi networks from the device. */ + LoadingNetworks, + + /** Connected and networks loaded — the main configuration screen. */ + Connected, + + /** Sending WiFi credentials to the device. */ + Provisioning, + } + + enum class ProvisionStatus { + Idle, + Success, + Failed, + } +} + +/** + * Typed error categories for the WiFi provisioning flow. + * + * Formatted into user-visible strings in the UI layer using string resources, keeping the ViewModel free of + * locale-specific text. + */ +sealed interface WifiProvisionError { + /** Detail message from the underlying exception (language-agnostic, typically from the BLE stack). */ + val detail: String + + /** BLE connection to the provisioning device failed. */ + data class ConnectFailed(override val detail: String) : WifiProvisionError + + /** WiFi network scan on the device failed. */ + data class ScanFailed(override val detail: String) : WifiProvisionError + + /** Sending WiFi credentials to the device failed. */ + data class ProvisionFailed(override val detail: String) : WifiProvisionError +} + +// --------------------------------------------------------------------------- +// ViewModel +// --------------------------------------------------------------------------- + +/** + * ViewModel for the WiFi provisioning flow. + * + * Uses [Factory] scope so a fresh [NymeaWifiService] (and its own [BleConnectionFactory]-backed + * [org.meshtastic.core.ble.BleConnection]) is created for each provisioning session. + */ +@Factory +class WifiProvisionViewModel( + private val bleScanner: BleScanner, + private val bleConnectionFactory: BleConnectionFactory, +) : ViewModel() { + + private val _uiState = MutableStateFlow(WifiProvisionUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Lazily-created service; reset on [reset]. */ + private var service: NymeaWifiService? = null + + // region Public actions (called from UI) + + /** + * Scan for the nearest nymea-networkmanager device and connect to it. Pauses at the + * [WifiProvisionUiState.Phase.DeviceFound] phase so the user can confirm before proceeding — this is the Android + * analog of the web flasher's native BLE pairing dialog. + * + * @param address Optional MAC address to target a specific device. + */ + fun connectToDevice(address: String? = null) { + _uiState.update { it.copy(phase = WifiProvisionUiState.Phase.ConnectingBle, error = null) } + + viewModelScope.launch { + val nymeaService = NymeaWifiService(bleScanner, bleConnectionFactory) + service = nymeaService + + nymeaService + .connect(address) + .onSuccess { deviceName -> + Logger.i { "$TAG: BLE connected to: $deviceName" } + _uiState.update { it.copy(phase = WifiProvisionUiState.Phase.DeviceFound, deviceName = deviceName) } + } + .onFailure { e -> + Logger.e(e) { "$TAG: BLE connect failed" } + _uiState.update { + it.copy( + phase = WifiProvisionUiState.Phase.Idle, + error = WifiProvisionError.ConnectFailed(e.message ?: "Unknown error"), + ) + } + } + } + } + + /** Called when the user confirms they want to scan networks after device discovery. */ + fun scanNetworks() { + val nymeaService = + service + ?: run { + connectToDevice() + return + } + viewModelScope.launch { loadNetworks(nymeaService) } + } + + /** + * Send WiFi credentials to the device. + * + * @param ssid The target network SSID. + * @param password The network password (empty string for open networks). + */ + fun provisionWifi(ssid: String, password: String) { + if (ssid.isBlank()) return + val nymeaService = service ?: return + + _uiState.update { + it.copy( + phase = WifiProvisionUiState.Phase.Provisioning, + error = null, + provisionStatus = WifiProvisionUiState.ProvisionStatus.Idle, + ) + } + + viewModelScope.launch { + when (val result = nymeaService.provision(ssid, password)) { + is ProvisionResult.Success -> { + Logger.i { "$TAG: Provisioned successfully" } + _uiState.update { + it.copy( + phase = WifiProvisionUiState.Phase.Connected, + provisionStatus = WifiProvisionUiState.ProvisionStatus.Success, + ) + } + } + is ProvisionResult.Failure -> { + Logger.w { "$TAG: Provision failed: ${result.message}" } + _uiState.update { + it.copy( + phase = WifiProvisionUiState.Phase.Connected, + provisionStatus = WifiProvisionUiState.ProvisionStatus.Failed, + error = WifiProvisionError.ProvisionFailed(result.message), + ) + } + } + } + } + } + + /** Disconnect and close any active BLE connection. */ + fun disconnect() { + viewModelScope.launch { + service?.close() + service = null + _uiState.value = WifiProvisionUiState() + } + } + + // endregion + + override fun onCleared() { + super.onCleared() + service?.cancel() + } + + // region Private helpers + + private suspend fun loadNetworks(nymeaService: NymeaWifiService) { + _uiState.update { it.copy(phase = WifiProvisionUiState.Phase.LoadingNetworks) } + + nymeaService + .scanNetworks() + .onSuccess { networks -> + _uiState.update { + it.copy(phase = WifiProvisionUiState.Phase.Connected, networks = deduplicateBySsid(networks)) + } + } + .onFailure { e -> + Logger.e(e) { "$TAG: scanNetworks failed" } + _uiState.update { + it.copy( + phase = WifiProvisionUiState.Phase.Connected, + error = WifiProvisionError.ScanFailed(e.message ?: "Unknown error"), + ) + } + } + } + + // endregion + + companion object { + private const val TAG = "WifiProvisionViewModel" + + /** + * Deduplicate networks by SSID, keeping the entry with the strongest signal for each. Since we only send SSID + * (not BSSID) to the device, showing duplicates is confusing. + */ + internal fun deduplicateBySsid(networks: List): List = networks + .groupBy { it.ssid } + .map { (_, entries) -> entries.maxBy { it.signalStrength } } + .sortedByDescending { it.signalStrength } + } +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/di/FeatureWifiProvisionModule.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/di/FeatureWifiProvisionModule.kt new file mode 100644 index 000000000..a05cbcfe9 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/di/FeatureWifiProvisionModule.kt @@ -0,0 +1,24 @@ +/* + * 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.wifiprovision.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module + +@Module +@ComponentScan("org.meshtastic.feature.wifiprovision") +class FeatureWifiProvisionModule diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaPacketCodec.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaPacketCodec.kt new file mode 100644 index 000000000..d5bb55fa8 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaPacketCodec.kt @@ -0,0 +1,80 @@ +/* + * 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.wifiprovision.domain + +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.MAX_PACKET_SIZE +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.STREAM_TERMINATOR + +/** + * Codec for the nymea-networkmanager BLE framing protocol. + * + * The protocol transfers JSON over BLE using packets capped at [MAX_PACKET_SIZE] bytes (20). A complete message is + * terminated by a newline character (`\n`) at the end of the final packet. + * + * **Sending:** call [encode] to split a compact JSON string into an ordered list of byte-array packets, each ≤ + * [maxPacketSize] bytes. The last packet always ends with `\n`. + * + * **Receiving:** feed incoming BLE notification bytes into [Reassembler]. It accumulates UTF-8 chunks and emits a + * complete JSON string once it sees the `\n` terminator. + */ +internal object NymeaPacketCodec { + + /** + * Encodes [json] (without trailing newline) into a list of BLE packets, each ≤ [maxPacketSize] bytes. The `\n` + * terminator is appended before chunking so it lands inside the final packet. + */ + fun encode(json: String, maxPacketSize: Int = MAX_PACKET_SIZE): List { + val payload = (json + STREAM_TERMINATOR).encodeToByteArray() + val packets = mutableListOf() + var offset = 0 + while (offset < payload.size) { + val end = minOf(offset + maxPacketSize, payload.size) + packets += payload.copyOfRange(offset, end) + offset = end + } + return packets + } + + /** + * Stateful reassembler for inbound BLE notification packets. + * + * Feed each raw notification into [feed]. When a packet ending with `\n` is received the accumulated UTF-8 string + * (minus the terminator) is returned; otherwise `null` is returned and the partial data is buffered. + * + * Not thread-safe — callers must serialise access (e.g., collect in a single coroutine). + */ + class Reassembler { + private val buffer = StringBuilder() + + /** Feed the next BLE notification payload. Returns the complete JSON string or `null`. */ + fun feed(bytes: ByteArray): String? { + buffer.append(bytes.decodeToString()) + return if (buffer.endsWith(STREAM_TERMINATOR)) { + val message = buffer.dropLast(1).toString() + buffer.clear() + message + } else { + null + } + } + + /** Discard any partial data accumulated so far. */ + fun reset() { + buffer.clear() + } + } +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt new file mode 100644 index 000000000..2519595d1 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt @@ -0,0 +1,96 @@ +/* + * 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.wifiprovision.domain + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * kotlinx.serialization models for the nymea-networkmanager JSON-over-BLE protocol. + * + * All messages are compact JSON objects terminated with a newline (`\n`) and chunked into ≤20-byte BLE + * notification/write packets. + * + * Reference: https://github.com/nymea/nymea-networkmanager#bluetooth-gatt-profile + */ + +// --------------------------------------------------------------------------- +// Shared JSON codec — lenient so unknown fields are silently ignored +// --------------------------------------------------------------------------- + +internal val NymeaJson = Json { + ignoreUnknownKeys = true + isLenient = true +} + +// --------------------------------------------------------------------------- +// Commands (app → device) +// --------------------------------------------------------------------------- + +/** A command with no parameters (e.g. GetNetworks, TriggerScan). */ +@Serializable internal data class NymeaSimpleCommand(@SerialName("c") val command: Int) + +/** The parameter payload for the Connect / ConnectHidden commands. */ +@Serializable +internal data class NymeaConnectParams( + /** SSID (nymea key: `e`). */ + @SerialName("e") val ssid: String, + /** Password (nymea key: `p`). */ + @SerialName("p") val password: String, +) + +/** A command that carries a [NymeaConnectParams] payload. */ +@Serializable +internal data class NymeaConnectCommand( + @SerialName("c") val command: Int, + @SerialName("p") val params: NymeaConnectParams, +) + +// --------------------------------------------------------------------------- +// Responses (device → app) +// --------------------------------------------------------------------------- + +/** Generic response — present in every reply from the device. */ +@Serializable +internal data class NymeaResponse( + /** Echo of the command code. */ + @SerialName("c") val command: Int = -1, + /** 0 = success; non-zero = error code. */ + @SerialName("r") val responseCode: Int = 0, +) + +/** One entry in the GetNetworks (`c=0`) response payload. */ +@Serializable +internal data class NymeaNetworkEntry( + /** SSID (nymea key: `e`). */ + @SerialName("e") val ssid: String, + /** BSSID / MAC address (nymea key: `m`). */ + @SerialName("m") val bssid: String = "", + /** Signal strength in dBm (nymea key: `s`). */ + @SerialName("s") val signalStrength: Int = 0, + /** 0 = open, 1 = protected (nymea key: `p`). */ + @SerialName("p") val protection: Int = 0, +) + +/** Full GetNetworks response including the network list. */ +@Serializable +internal data class NymeaNetworksResponse( + @SerialName("c") val command: Int = -1, + @SerialName("r") val responseCode: Int = 0, + @SerialName("p") val networks: List = emptyList(), +) diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt new file mode 100644 index 000000000..067dec798 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt @@ -0,0 +1,256 @@ +/* + * 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.wifiprovision.domain + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.encodeToString +import org.meshtastic.core.ble.BleCharacteristic +import org.meshtastic.core.ble.BleConnectionFactory +import org.meshtastic.core.ble.BleConnectionState +import org.meshtastic.core.ble.BleScanner +import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.feature.wifiprovision.NymeaBleConstants +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_GET_NETWORKS +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_SCAN +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_SUCCESS +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_TIMEOUT_MS +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SCAN_TIMEOUT_MS +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SUBSCRIPTION_SETTLE_MS +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER_UUID +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_SERVICE_UUID +import org.meshtastic.feature.wifiprovision.model.ProvisionResult +import org.meshtastic.feature.wifiprovision.model.WifiNetwork +import kotlin.time.Duration.Companion.milliseconds + +/** + * GATT client for the nymea-networkmanager WiFi provisioning profile. + * + * Responsibilities: + * - Scan for a device advertising [WIRELESS_SERVICE_UUID]. + * - Connect and subscribe to the Commander Response characteristic. + * - Send JSON commands (chunked into ≤20-byte BLE packets) via the Wireless Commander characteristic. + * - Reassemble newline-terminated JSON responses from notification packets. + * - Parse the nymea JSON protocol into typed Kotlin results. + * + * Lifecycle: create once per provisioning session, call [connect], use [scanNetworks] / [provision], then [close]. + */ +class NymeaWifiService( + private val scanner: BleScanner, + connectionFactory: BleConnectionFactory, + dispatcher: CoroutineDispatcher = Dispatchers.Default, +) { + + private val serviceScope = CoroutineScope(SupervisorJob() + dispatcher) + private val bleConnection = connectionFactory.create(serviceScope, TAG) + + private val commanderChar = BleCharacteristic(WIRELESS_COMMANDER_UUID) + private val responseChar = BleCharacteristic(COMMANDER_RESPONSE_UUID) + + /** Unbounded channel — the observer coroutine feeds complete JSON strings here. */ + private val responseChannel = Channel(Channel.UNLIMITED) + private val reassembler = NymeaPacketCodec.Reassembler() + + // region Public API + + /** + * Scan for a device advertising the nymea wireless service and connect to it. + * + * @param address Optional MAC address filter. If null, the first advertising device is used. + * @return The discovered device's advertised name on success. + * @throws IllegalStateException if no device is found within [SCAN_TIMEOUT_MS]. + */ + suspend fun connect(address: String? = null): Result = runCatching { + Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" } + + val device = + withTimeout(SCAN_TIMEOUT_MS) { + scanner + .scan( + timeout = SCAN_TIMEOUT_MS.milliseconds, + serviceUuid = WIRELESS_SERVICE_UUID, + address = address, + ) + .first() + } + + val deviceName = device.name ?: device.address + Logger.i { "$TAG: Found device: ${device.name} @ ${device.address}" } + + val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT_MS) + check(state is BleConnectionState.Connected) { "Failed to connect to ${device.address} — final state: $state" } + + Logger.i { "$TAG: Connected. Discovering wireless service…" } + + bleConnection.profile(WIRELESS_SERVICE_UUID) { service -> + val subscribed = CompletableDeferred() + + service + .observe(responseChar) + .onEach { bytes -> + val message = reassembler.feed(bytes) + if (message != null) { + Logger.d { "$TAG: ← $message" } + responseChannel.trySend(message) + } + if (!subscribed.isCompleted) subscribed.complete(Unit) + } + .catch { e -> + Logger.e(e) { "$TAG: Error in response characteristic subscription" } + if (!subscribed.isCompleted) subscribed.completeExceptionally(e) + } + .launchIn(this) + + delay(SUBSCRIPTION_SETTLE_MS) + if (!subscribed.isCompleted) subscribed.complete(Unit) + subscribed.await() + + Logger.i { "$TAG: Wireless service ready" } + } + + deviceName + } + + /** + * Trigger a fresh WiFi scan on the device, then return the list of visible networks. + * + * Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0). + */ + suspend fun scanNetworks(): Result> = runCatching { + // Trigger scan + sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN))) + val scanAck = NymeaJson.decodeFromString(waitForResponse()) + if (scanAck.responseCode != RESPONSE_SUCCESS) { + error("Scan command failed: ${nymeaErrorMessage(scanAck.responseCode)}") + } + + // Fetch results + sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_NETWORKS))) + val networksResponse = NymeaJson.decodeFromString(waitForResponse()) + if (networksResponse.responseCode != RESPONSE_SUCCESS) { + error("GetNetworks failed: ${nymeaErrorMessage(networksResponse.responseCode)}") + } + + networksResponse.networks.map { entry -> + WifiNetwork( + ssid = entry.ssid, + bssid = entry.bssid, + signalStrength = entry.signalStrength, + isProtected = entry.protection != 0, + ) + } + } + + /** + * Provision the device with the given WiFi credentials. + * + * Sends CMD_CONNECT (1) or CMD_CONNECT_HIDDEN (2) with the SSID and password. The response error code is mapped to + * a [ProvisionResult]. + * + * @param ssid The target network SSID. + * @param password The network password. Pass an empty string for open networks. + * @param hidden Set to `true` to target a hidden (non-broadcasting) network. + */ + suspend fun provision(ssid: String, password: String, hidden: Boolean = false): ProvisionResult { + val cmd = if (hidden) CMD_CONNECT_HIDDEN else CMD_CONNECT + val json = + NymeaJson.encodeToString( + NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)), + ) + + return runCatching { + sendCommand(json) + val response = NymeaJson.decodeFromString(waitForResponse()) + if (response.responseCode == RESPONSE_SUCCESS) { + ProvisionResult.Success + } else { + ProvisionResult.Failure(response.responseCode, nymeaErrorMessage(response.responseCode)) + } + } + .getOrElse { e -> + Logger.e(e) { "$TAG: Provision failed" } + ProvisionResult.Failure(-1, e.message ?: "Unknown error") + } + } + + /** Disconnect and cancel the service scope. */ + suspend fun close() { + bleConnection.disconnect() + reassembler.reset() + serviceScope.cancel() + } + + /** + * Synchronous teardown — cancels the service scope (and its child BLE connection) without suspending. + * + * Use this from `ViewModel.onCleared()` where `viewModelScope` is already cancelled and launching a new coroutine + * is not possible. + */ + fun cancel() { + reassembler.reset() + serviceScope.cancel() + } + + // endregion + + // region Internal helpers + + /** Encode [json] into ≤20-byte packets and write each one WITH_RESPONSE to the commander characteristic. */ + private suspend fun sendCommand(json: String) { + Logger.d { "$TAG: → $json" } + val packets = NymeaPacketCodec.encode(json) + bleConnection.profile(WIRELESS_SERVICE_UUID) { service -> + for (packet in packets) { + service.write(commanderChar, packet, BleWriteType.WITH_RESPONSE) + } + } + } + + /** Wait up to [RESPONSE_TIMEOUT_MS] for a complete JSON response from the notification channel. */ + private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT_MS) { responseChannel.receive() } + + private fun nymeaErrorMessage(code: Int): String = when (code) { + NymeaBleConstants.RESPONSE_INVALID_COMMAND -> "Invalid command" + NymeaBleConstants.RESPONSE_INVALID_PARAMETER -> "Invalid parameter" + NymeaBleConstants.RESPONSE_NETWORK_MANAGER_UNAVAILABLE -> "NetworkManager not available" + NymeaBleConstants.RESPONSE_WIRELESS_UNAVAILABLE -> "Wireless adapter not available" + NymeaBleConstants.RESPONSE_NETWORKING_DISABLED -> "Networking disabled" + NymeaBleConstants.RESPONSE_WIRELESS_DISABLED -> "Wireless disabled" + else -> "Unknown error (code $code)" + } + + // endregion + + companion object { + private const val TAG = "NymeaWifiService" + } +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt new file mode 100644 index 000000000..50a497c5e --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt @@ -0,0 +1,36 @@ +/* + * 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.wifiprovision.model + +/** A WiFi access point returned by the nymea GetNetworks command. */ +data class WifiNetwork( + /** ESSID / network name. */ + val ssid: String, + /** MAC address of the access point. */ + val bssid: String, + /** Signal strength [0-100] %. */ + val signalStrength: Int, + /** Whether the network requires a password. */ + val isProtected: Boolean, +) + +/** Result of a WiFi provisioning attempt. */ +sealed interface ProvisionResult { + data object Success : ProvisionResult + + data class Failure(val errorCode: Int, val message: String) : ProvisionResult +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt new file mode 100644 index 000000000..472f1effe --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/navigation/WifiProvisionNavigation.kt @@ -0,0 +1,39 @@ +/* + * 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.wifiprovision.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import org.meshtastic.core.navigation.WifiProvisionRoutes +import org.meshtastic.feature.wifiprovision.ui.WifiProvisionScreen + +/** + * Registers the WiFi provisioning graph entries into the host navigation provider. + * + * Both the graph sentinel ([WifiProvisionRoutes.WifiProvisionGraph]) and the primary screen + * ([WifiProvisionRoutes.WifiProvision]) navigate to the same composable so that the feature can be reached via either a + * top-level push or a deep-link graph push. + */ +fun EntryProviderScope.wifiProvisionGraph(backStack: NavBackStack) { + entry { + WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }) + } + entry { key -> + WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address) + } +} diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt new file mode 100644 index 000000000..a2ad7cfe9 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/ProvisionStatusCard.kt @@ -0,0 +1,101 @@ +/* + * 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.wifiprovision.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.wifi_provision_sending_credentials +import org.meshtastic.core.resources.wifi_provision_status_applied +import org.meshtastic.core.resources.wifi_provision_status_failed +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus + +/** Inline status card matching the web flasher's colored status feedback. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ProvisionStatusCard(provisionStatus: ProvisionStatus, isProvisioning: Boolean) { + val colors = statusCardColors(provisionStatus, isProvisioning) + + Card( + colors = CardDefaults.cardColors(containerColor = colors.first), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + StatusIcon(provisionStatus = provisionStatus, isProvisioning = isProvisioning, tint = colors.second) + Text( + text = statusText(provisionStatus, isProvisioning), + style = MaterialTheme.typography.bodyMediumEmphasized, + color = colors.second, + ) + } + } +} + +/** Resolve container + content color pair for the provision status card. */ +@Composable +private fun statusCardColors(provisionStatus: ProvisionStatus, isProvisioning: Boolean): Pair = when { + isProvisioning -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer + provisionStatus == ProvisionStatus.Success -> + MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + provisionStatus == ProvisionStatus.Failed -> + MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurface +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun StatusIcon(provisionStatus: ProvisionStatus, isProvisioning: Boolean, tint: Color) { + when { + isProvisioning -> LoadingIndicator(modifier = Modifier.size(20.dp), color = tint) + provisionStatus == ProvisionStatus.Success -> + Icon(Icons.Rounded.CheckCircle, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) + provisionStatus == ProvisionStatus.Failed -> + Icon(Icons.Rounded.Error, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) + } +} + +@Composable +private fun statusText(provisionStatus: ProvisionStatus, isProvisioning: Boolean): String = when { + isProvisioning -> stringResource(Res.string.wifi_provision_sending_credentials) + provisionStatus == ProvisionStatus.Success -> stringResource(Res.string.wifi_provision_status_applied) + provisionStatus == ProvisionStatus.Failed -> stringResource(Res.string.wifi_provision_status_failed) + else -> "" +} 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 new file mode 100644 index 000000000..0bb2100aa --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt @@ -0,0 +1,348 @@ +/* + * 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 . + */ +@file:Suppress("TooManyFunctions", "MagicNumber") + +package org.meshtastic.feature.wifiprovision.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus +import org.meshtastic.feature.wifiprovision.model.WifiNetwork + +// --------------------------------------------------------------------------- +// Sample data for previews +// --------------------------------------------------------------------------- + +private val sampleNetworks = + listOf( + WifiNetwork(ssid = "Meshtastic-HQ", bssid = "AA:BB:CC:DD:EE:01", signalStrength = 92, isProtected = true), + WifiNetwork(ssid = "CoffeeShop-Free", bssid = "AA:BB:CC:DD:EE:02", signalStrength = 74, isProtected = false), + WifiNetwork(ssid = "OffGrid-5G", bssid = "AA:BB:CC:DD:EE:03", signalStrength = 58, isProtected = true), + WifiNetwork(ssid = "Neighbor-Net", bssid = "AA:BB:CC:DD:EE:04", signalStrength = 31, isProtected = true), + ) + +private val edgeCaseNetworks = + listOf( + WifiNetwork( + ssid = "My Super Long WiFi Network Name That Goes On And On Forever", + bssid = "AA:BB:CC:DD:EE:10", + signalStrength = 85, + isProtected = true, + ), + WifiNetwork(ssid = "x", bssid = "AA:BB:CC:DD:EE:11", signalStrength = 99, isProtected = false), + WifiNetwork( + ssid = "Hidden-char \u200B\u200B", + bssid = "AA:BB:CC:DD:EE:12", + signalStrength = 42, + isProtected = true, + ), + ) + +private val manyNetworks = + (1..20).map { i -> + WifiNetwork( + ssid = "Network-$i", + bssid = "AA:BB:CC:DD:EE:${i.toString().padStart(2, '0')}", + signalStrength = (100 - i * 4).coerceAtLeast(5), + isProtected = i % 3 != 0, + ) + } + +private val noOp: () -> Unit = {} +private val noOpProvision: (String, String) -> Unit = { _, _ -> } + +// --------------------------------------------------------------------------- +// Phase 1: BLE scanning +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun ScanningBlePreview() { + AppTheme { Surface(Modifier.fillMaxSize()) { ScanningBleContent() } } +} + +// --------------------------------------------------------------------------- +// Phase 2: Device found confirmation +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun DeviceFoundPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + DeviceFoundContent(deviceName = "mpwrd-nm-A1B2", onProceed = noOp, onCancel = noOp) + } + } +} + +@PreviewLightDark +@Composable +private fun DeviceFoundNoNamePreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { DeviceFoundContent(deviceName = null, onProceed = noOp, onCancel = noOp) } + } +} + +// --------------------------------------------------------------------------- +// Phase 3: WiFi network scanning +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun ScanningNetworksPreview() { + AppTheme { Surface(Modifier.fillMaxSize()) { ScanningNetworksContent() } } +} + +// --------------------------------------------------------------------------- +// Phase 4: Connected — main configuration screen variants +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun ConnectedWithNetworksPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = sampleNetworks, + provisionStatus = ProvisionStatus.Idle, + isProvisioning = false, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConnectedEmptyNetworksPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = emptyList(), + provisionStatus = ProvisionStatus.Idle, + isProvisioning = false, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConnectedScanningPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = sampleNetworks, + provisionStatus = ProvisionStatus.Idle, + isProvisioning = false, + isScanning = true, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConnectedProvisioningPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = sampleNetworks, + provisionStatus = ProvisionStatus.Idle, + isProvisioning = true, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConnectedSuccessPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = sampleNetworks, + provisionStatus = ProvisionStatus.Success, + isProvisioning = false, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConnectedFailedPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = sampleNetworks, + provisionStatus = ProvisionStatus.Failed, + isProvisioning = false, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Edge-case previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun ConnectedLongSsidPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = edgeCaseNetworks, + provisionStatus = ProvisionStatus.Idle, + isProvisioning = false, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConnectedManyNetworksPreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + ConnectedContent( + networks = manyNetworks, + provisionStatus = ProvisionStatus.Idle, + isProvisioning = false, + isScanning = false, + onScanNetworks = noOp, + onProvision = noOpProvision, + onDisconnect = noOp, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DeviceFoundLongNamePreview() { + AppTheme { + Surface(Modifier.fillMaxSize()) { + DeviceFoundContent( + deviceName = "mpwrd-nm-A1B2C3D4E5F6-extra-long-identifier", + onProceed = noOp, + onCancel = noOp, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Standalone component previews +// --------------------------------------------------------------------------- + +@PreviewLightDark +@Composable +private fun ProvisionStatusCardProvisioningPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + ProvisionStatusCard(provisionStatus = ProvisionStatus.Idle, isProvisioning = true) + } + } + } +} + +@PreviewLightDark +@Composable +private fun ProvisionStatusCardSuccessPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + ProvisionStatusCard(provisionStatus = ProvisionStatus.Success, isProvisioning = false) + } + } + } +} + +@PreviewLightDark +@Composable +private fun ProvisionStatusCardFailedPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + ProvisionStatusCard(provisionStatus = ProvisionStatus.Failed, isProvisioning = false) + } + } + } +} + +@PreviewLightDark +@Composable +private fun NetworkRowPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth()) { + NetworkRow(network = sampleNetworks[0], isSelected = false, onClick = noOp) + NetworkRow(network = sampleNetworks[1], isSelected = true, onClick = noOp) + } + } + } +} + +@PreviewLightDark +@Composable +private fun NetworkRowLongSsidPreview() { + AppTheme { + Surface { + Column(modifier = Modifier.fillMaxWidth()) { + NetworkRow(network = edgeCaseNetworks[0], isSelected = false, onClick = noOp) + NetworkRow(network = edgeCaseNetworks[1], isSelected = true, onClick = noOp) + } + } + } +} 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 new file mode 100644 index 000000000..6f9c9dc68 --- /dev/null +++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt @@ -0,0 +1,497 @@ +/* + * 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.wifiprovision.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.Bluetooth +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.apply +import org.meshtastic.core.resources.back +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.hide_password +import org.meshtastic.core.resources.password +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_no_networks +import org.meshtastic.core.resources.wifi_provision_scan_failed +import org.meshtastic.core.resources.wifi_provision_scan_networks +import org.meshtastic.core.resources.wifi_provision_scanning_ble +import org.meshtastic.core.resources.wifi_provision_scanning_wifi +import org.meshtastic.core.resources.wifi_provision_sending_credentials +import org.meshtastic.core.resources.wifi_provision_signal_strength +import org.meshtastic.core.resources.wifi_provision_ssid_label +import org.meshtastic.core.resources.wifi_provision_ssid_placeholder +import org.meshtastic.core.resources.wifi_provisioning +import org.meshtastic.feature.wifiprovision.WifiProvisionError +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus +import org.meshtastic.feature.wifiprovision.WifiProvisionViewModel +import org.meshtastic.feature.wifiprovision.model.WifiNetwork + +private const val NETWORK_LIST_MAX_HEIGHT_DP = 240 + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Suppress("LongMethod") +@Composable +fun WifiProvisionScreen( + onNavigateUp: () -> Unit, + address: String? = null, + viewModel: WifiProvisionViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + val errorMessage = + uiState.error?.let { error -> + when (error) { + is WifiProvisionError.ConnectFailed -> + stringResource(Res.string.wifi_provision_connect_failed, error.detail) + is WifiProvisionError.ScanFailed -> stringResource(Res.string.wifi_provision_scan_failed, error.detail) + is WifiProvisionError.ProvisionFailed -> error.detail + } + } + + LaunchedEffect(uiState.error) { errorMessage?.let { snackbarHostState.showSnackbar(it) } } + LaunchedEffect(Unit) { viewModel.connectToDevice(address) } + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(stringResource(Res.string.wifi_provisioning)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize().animateContentSize()) { + // Indeterminate progress bar for active operations + if (uiState.phase.isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + Spacer(Modifier.height(4.dp)) + } + + Crossfade(targetState = screenKey(uiState), label = "wifi_provision") { key -> + when (key) { + ScreenKey.ConnectingBle -> ScanningBleContent() + ScreenKey.DeviceFound -> + DeviceFoundContent( + deviceName = uiState.deviceName, + onProceed = viewModel::scanNetworks, + onCancel = onNavigateUp, + ) + ScreenKey.LoadingNetworks -> ScanningNetworksContent() + ScreenKey.Connected -> + ConnectedContent( + networks = uiState.networks, + provisionStatus = uiState.provisionStatus, + isProvisioning = uiState.phase == Phase.Provisioning, + isScanning = uiState.phase == Phase.LoadingNetworks, + onScanNetworks = viewModel::scanNetworks, + onProvision = viewModel::provisionWifi, + onDisconnect = { + viewModel.disconnect() + onNavigateUp() + }, + ) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Screen-key helper for Crossfade +// --------------------------------------------------------------------------- + +private enum class ScreenKey { + ConnectingBle, + DeviceFound, + LoadingNetworks, + Connected, +} + +private fun screenKey(state: WifiProvisionUiState): ScreenKey = when (state.phase) { + Phase.Idle, + Phase.ConnectingBle, + -> ScreenKey.ConnectingBle + Phase.DeviceFound -> ScreenKey.DeviceFound + Phase.LoadingNetworks -> if (state.networks.isEmpty()) ScreenKey.LoadingNetworks else ScreenKey.Connected + Phase.Connected, + Phase.Provisioning, + -> ScreenKey.Connected +} + +private val Phase.isLoading: Boolean + get() = this == Phase.ConnectingBle || this == Phase.LoadingNetworks || this == Phase.Provisioning + +// --------------------------------------------------------------------------- +// Sub-composables +// --------------------------------------------------------------------------- + +/** BLE scanning spinner — shown while searching for a device. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ScanningBleContent() { + CenteredStatusContent { + LoadingIndicator(modifier = Modifier.size(48.dp)) + Spacer(Modifier.height(24.dp)) + Text(stringResource(Res.string.wifi_provision_scanning_ble), style = MaterialTheme.typography.bodyLarge) + } +} + +/** + * Confirmation step shown after BLE device discovery — the Android analog of the web flasher's native BLE pairing + * prompt. Gives the user a clear "device found" moment before proceeding. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun DeviceFoundContent(deviceName: String?, onProceed: () -> Unit, onCancel: () -> Unit) { + CenteredStatusContent { + Icon( + Icons.Rounded.Bluetooth, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.wifi_provision_device_found), + style = MaterialTheme.typography.headlineSmallEmphasized, + textAlign = TextAlign.Center, + ) + if (deviceName != null) { + Spacer(Modifier.height(4.dp)) + Text( + deviceName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(Res.string.wifi_provision_device_found_detail), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) } + Button(onClick = onProceed) { Text(stringResource(Res.string.wifi_provision_scan_networks)) } + } + } +} + +/** Network scanning spinner — shown during the initial scan when no networks are loaded yet. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ScanningNetworksContent() { + CenteredStatusContent { + LoadingIndicator(modifier = Modifier.size(48.dp)) + Spacer(Modifier.height(24.dp)) + Text(stringResource(Res.string.wifi_provision_scanning_wifi), style = MaterialTheme.typography.bodyLarge) + } +} + +/** + * Main configuration screen shown after BLE connection — mirrors the web flasher's connected state. All controls (scan + * button, network list, SSID/password fields, Apply, status) are on one screen. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Suppress("LongMethod", "LongParameterList") +@Composable +internal fun ConnectedContent( + networks: List, + provisionStatus: ProvisionStatus, + isProvisioning: Boolean, + isScanning: Boolean, + onScanNetworks: () -> Unit, + onProvision: (ssid: String, password: String) -> Unit, + onDisconnect: () -> Unit, +) { + var ssid by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + val haptic = LocalHapticFeedback.current + LaunchedEffect(provisionStatus) { + if (provisionStatus == ProvisionStatus.Success) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + + Column( + modifier = + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource(Res.string.wifi_provision_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Scan button — FilledTonalButton for prominent secondary action + FilledTonalButton( + onClick = onScanNetworks, + enabled = !isScanning && !isProvisioning, + modifier = Modifier.fillMaxWidth(), + ) { + if (isScanning) { + LoadingIndicator(modifier = Modifier.size(18.dp)) + } else { + Icon(Icons.Rounded.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) + } + Spacer(Modifier.width(8.dp)) + Text( + if (isScanning) { + stringResource(Res.string.wifi_provision_scanning_wifi) + } else { + stringResource(Res.string.wifi_provision_scan_networks) + }, + ) + } + + // Network list (scrollable, capped height) — animated entrance + AnimatedVisibility( + visible = networks.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + Text( + stringResource(Res.string.wifi_provision_available_networks), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Card( + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + LazyColumn(modifier = Modifier.heightIn(max = NETWORK_LIST_MAX_HEIGHT_DP.dp)) { + items(networks, key = { it.ssid }) { network -> + NetworkRow( + network = network, + isSelected = network.ssid == ssid, + onClick = { ssid = network.ssid }, + ) + } + } + } + } + } + + AnimatedVisibility(visible = networks.isEmpty() && !isScanning, enter = fadeIn(), exit = fadeOut()) { + Text( + stringResource(Res.string.wifi_provision_no_networks), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + // SSID input + OutlinedTextField( + value = ssid, + onValueChange = { ssid = it }, + label = { Text(stringResource(Res.string.wifi_provision_ssid_label)) }, + placeholder = { Text(stringResource(Res.string.wifi_provision_ssid_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Password input + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(Res.string.password)) }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + contentDescription = + if (passwordVisible) { + stringResource(Res.string.hide_password) + } else { + stringResource(Res.string.show_password) + }, + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onProvision(ssid, password) }), + modifier = Modifier.fillMaxWidth(), + ) + + // Inline provision status (matches web flasher's status chip) — animated entrance + AnimatedVisibility( + visible = provisionStatus != ProvisionStatus.Idle || isProvisioning, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ProvisionStatusCard(provisionStatus = provisionStatus, isProvisioning = isProvisioning) + } + + // Action buttons — cancel left, primary action right (app convention) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onDisconnect) { Text(stringResource(Res.string.cancel)) } + Button( + onClick = { onProvision(ssid, password) }, + enabled = ssid.isNotBlank() && !isProvisioning, + modifier = Modifier.weight(1f), + ) { + if (isProvisioning) { + LoadingIndicator(modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.wifi_provision_sending_credentials)) + } else { + Icon(Icons.Rounded.Wifi, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.apply)) + } + } + } + } +} + +@Composable +internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () -> Unit) { + val containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + ListItem( + headlineContent = { Text(network.ssid) }, + supportingContent = { Text(stringResource(Res.string.wifi_provision_signal_strength, network.signalStrength)) }, + leadingContent = { + Icon(Icons.Rounded.Wifi, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + }, + trailingContent = { + if (network.isProtected) { + Icon( + Icons.Rounded.Lock, + contentDescription = stringResource(Res.string.password), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = ListItemDefaults.colors(containerColor = containerColor), + modifier = Modifier.clickable(onClick = onClick), + ) +} + +// --------------------------------------------------------------------------- +// Shared layout wrapper for centered status screens +// --------------------------------------------------------------------------- + +@Composable +private fun CenteredStatusContent(content: @Composable () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + } +} diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/DeduplicateBySsidTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/DeduplicateBySsidTest.kt new file mode 100644 index 000000000..2ad2e1fcc --- /dev/null +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/DeduplicateBySsidTest.kt @@ -0,0 +1,100 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.wifiprovision + +import org.meshtastic.feature.wifiprovision.model.WifiNetwork +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Tests for [WifiProvisionViewModel.deduplicateBySsid]. */ +class DeduplicateBySsidTest { + + private fun network(ssid: String, signal: Int, bssid: String = "00:00:00:00:00:00") = + WifiNetwork(ssid = ssid, bssid = bssid, signalStrength = signal, isProtected = true) + + @Test + fun `empty list returns empty`() { + val result = WifiProvisionViewModel.deduplicateBySsid(emptyList()) + assertTrue(result.isEmpty()) + } + + @Test + fun `single network is returned unchanged`() { + val input = listOf(network("HomeWifi", 80)) + val result = WifiProvisionViewModel.deduplicateBySsid(input) + assertEquals(1, result.size) + assertEquals("HomeWifi", result[0].ssid) + assertEquals(80, result[0].signalStrength) + } + + @Test + fun `duplicate SSIDs keep strongest signal`() { + val input = + listOf( + network("HomeWifi", 50, bssid = "AA:BB:CC:DD:EE:01"), + network("HomeWifi", 90, bssid = "AA:BB:CC:DD:EE:02"), + network("HomeWifi", 70, bssid = "AA:BB:CC:DD:EE:03"), + ) + val result = WifiProvisionViewModel.deduplicateBySsid(input) + assertEquals(1, result.size) + assertEquals(90, result[0].signalStrength) + assertEquals("AA:BB:CC:DD:EE:02", result[0].bssid) + } + + @Test + fun `mixed duplicates and unique networks are all handled`() { + val input = + listOf( + network("Alpha", 40), + network("Beta", 80), + network("Alpha", 60), + network("Gamma", 30), + network("Beta", 50), + ) + val result = WifiProvisionViewModel.deduplicateBySsid(input) + assertEquals(3, result.size) + // Should be sorted by signal strength descending + assertEquals("Beta", result[0].ssid) + assertEquals(80, result[0].signalStrength) + assertEquals("Alpha", result[1].ssid) + assertEquals(60, result[1].signalStrength) + assertEquals("Gamma", result[2].ssid) + assertEquals(30, result[2].signalStrength) + } + + @Test + fun `result is sorted by signal strength descending`() { + val input = listOf(network("Weak", 10), network("Strong", 95), network("Medium", 55)) + val result = WifiProvisionViewModel.deduplicateBySsid(input) + assertEquals(listOf(95, 55, 10), result.map { it.signalStrength }) + } + + @Test + fun `preserves isProtected from strongest entry`() { + val input = + listOf( + WifiNetwork(ssid = "Net", bssid = "01", signalStrength = 30, isProtected = false), + WifiNetwork(ssid = "Net", bssid = "02", signalStrength = 90, isProtected = true), + ) + val result = WifiProvisionViewModel.deduplicateBySsid(input) + assertEquals(1, result.size) + assertTrue(result[0].isProtected, "Should keep isProtected from the strongest-signal entry") + } +} diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt new file mode 100644 index 000000000..65798a13b --- /dev/null +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt @@ -0,0 +1,325 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.wifiprovision + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.testing.FakeBleConnection +import org.meshtastic.core.testing.FakeBleConnectionFactory +import org.meshtastic.core.testing.FakeBleDevice +import org.meshtastic.core.testing.FakeBleScanner +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase +import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.ProvisionStatus +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [WifiProvisionViewModel] covering the full state machine: BLE connect, device found, scan networks, + * provisioning, disconnect, and error paths. + * + * The ViewModel creates [NymeaWifiService] internally with the injected [BleScanner] and [BleConnectionFactory], so we + * drive the flow end-to-end via BLE fakes. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WifiProvisionViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var scanner: FakeBleScanner + private lateinit var connection: FakeBleConnection + private lateinit var viewModel: WifiProvisionViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + scanner = FakeBleScanner() + connection = FakeBleConnection() + viewModel = + WifiProvisionViewModel(bleScanner = scanner, bleConnectionFactory = FakeBleConnectionFactory(connection)) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + // ----------------------------------------------------------------------- + // Initial state + // ----------------------------------------------------------------------- + + @Test + fun `initial state is Idle with empty data`() { + val state = viewModel.uiState.value + assertEquals(Phase.Idle, state.phase) + assertTrue(state.networks.isEmpty()) + assertNull(state.error) + assertNull(state.deviceName) + assertEquals(ProvisionStatus.Idle, state.provisionStatus) + } + + // ----------------------------------------------------------------------- + // connectToDevice + // ----------------------------------------------------------------------- + + @Test + fun `connectToDevice transitions to ConnectingBle immediately`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF", name = "mpwrd-nm-1234")) + viewModel.connectToDevice() + + // After one dispatcher step, should be in ConnectingBle + assertEquals(Phase.ConnectingBle, viewModel.uiState.value.phase) + } + + @Test + fun `connectToDevice transitions to DeviceFound on success`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF", name = "mpwrd-nm-1234")) + viewModel.connectToDevice() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.DeviceFound, state.phase) + assertEquals("mpwrd-nm-1234", state.deviceName) + assertNull(state.error) + } + + @Test + fun `connectToDevice uses device address when name is null`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF", name = null)) + viewModel.connectToDevice() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.DeviceFound, state.phase) + assertEquals("AA:BB:CC:DD:EE:FF", state.deviceName) + } + + @Test + fun `connectToDevice sets error and returns to Idle on BLE connect failure`() = runTest { + connection.failNextN = 1 + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.Idle, state.phase) + assertIs(state.error) + } + + @Test + fun `connectToDevice sets error when connection throws exception`() = runTest { + connection.connectException = RuntimeException("BLE unavailable") + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.Idle, state.phase) + val error = assertIs(state.error) + assertTrue(error.detail.contains("BLE unavailable")) + } + + // ----------------------------------------------------------------------- + // scanNetworks + // ----------------------------------------------------------------------- + + @Test + fun `scanNetworks transitions to LoadingNetworks then Connected with results`() = runTest { + // First connect + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + assertEquals(Phase.DeviceFound, viewModel.uiState.value.phase) + + // Enqueue nymea responses: scan ack + networks response + emitNymeaResponse("""{"c":4,"r":0}""") + emitNymeaResponse("""{"c":0,"r":0,"p":[{"e":"TestNet","m":"AA:BB","s":80,"p":1}]}""") + + viewModel.scanNetworks() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.Connected, state.phase) + assertEquals(1, state.networks.size) + assertEquals("TestNet", state.networks[0].ssid) + assertEquals(80, state.networks[0].signalStrength) + assertTrue(state.networks[0].isProtected) + } + + @Test + fun `scanNetworks deduplicates networks by SSID`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + emitNymeaResponse("""{"c":4,"r":0}""") + emitNymeaResponse( + """{"c":0,"r":0,"p":[ + {"e":"Dup","m":"01","s":30,"p":1}, + {"e":"Dup","m":"02","s":90,"p":1}, + {"e":"Unique","m":"03","s":60,"p":0} + ]}""", + ) + + viewModel.scanNetworks() + advanceUntilIdle() + + val networks = viewModel.uiState.value.networks + assertEquals(2, networks.size, "Duplicates should be merged") + assertEquals("Dup", networks[0].ssid) + assertEquals(90, networks[0].signalStrength, "Should keep strongest signal") + } + + @Test + fun `scanNetworks reconnects if no service exists`() = runTest { + // Don't connect first — scanNetworks should trigger connectToDevice + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.scanNetworks() + advanceUntilIdle() + + // Should have connected (DeviceFound) via the reconnect path + assertEquals(Phase.DeviceFound, viewModel.uiState.value.phase) + } + + // ----------------------------------------------------------------------- + // provisionWifi + // ----------------------------------------------------------------------- + + @Test + fun `provisionWifi transitions to Provisioning then Connected with Success`() = runTest { + // Connect and scan first + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + emitNymeaResponse("""{"c":4,"r":0}""") + emitNymeaResponse("""{"c":0,"r":0,"p":[{"e":"Net","m":"01","s":80,"p":1}]}""") + viewModel.scanNetworks() + advanceUntilIdle() + + // Now provision — enqueue success response + emitNymeaResponse("""{"c":1,"r":0}""") + viewModel.provisionWifi("Net", "password123") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.Connected, state.phase) + assertEquals(ProvisionStatus.Success, state.provisionStatus) + } + + @Test + fun `provisionWifi sets Failed status on error response`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + emitNymeaResponse("""{"c":4,"r":0}""") + emitNymeaResponse("""{"c":0,"r":0,"p":[]}""") + viewModel.scanNetworks() + advanceUntilIdle() + + // Provision with error code 3 (NetworkManager unavailable) + emitNymeaResponse("""{"c":1,"r":3}""") + viewModel.provisionWifi("Net", "pass") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.Connected, state.phase) + assertEquals(ProvisionStatus.Failed, state.provisionStatus) + assertIs(state.error) + } + + @Test + fun `provisionWifi ignores blank SSID`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + val phaseBefore = viewModel.uiState.value.phase + viewModel.provisionWifi(" ", "pass") + advanceUntilIdle() + + // Phase should not change — blank SSID is a no-op + assertEquals(phaseBefore, viewModel.uiState.value.phase) + } + + @Test + fun `provisionWifi no-ops when service is null`() = runTest { + // Don't connect — service is null + viewModel.provisionWifi("Net", "pass") + advanceUntilIdle() + + assertEquals(Phase.Idle, viewModel.uiState.value.phase) + } + + // ----------------------------------------------------------------------- + // disconnect + // ----------------------------------------------------------------------- + + @Test + fun `disconnect resets state to initial`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + assertEquals(Phase.DeviceFound, viewModel.uiState.value.phase) + + viewModel.disconnect() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(Phase.Idle, state.phase) + assertTrue(state.networks.isEmpty()) + assertNull(state.deviceName) + assertEquals(ProvisionStatus.Idle, state.provisionStatus) + } + + @Test + fun `disconnect calls BLE disconnect`() = runTest { + scanner.emitDevice(FakeBleDevice("AA:BB:CC:DD:EE:FF")) + viewModel.connectToDevice() + advanceUntilIdle() + + viewModel.disconnect() + advanceUntilIdle() + + assertTrue(connection.disconnectCalls >= 1, "BLE disconnect should be called") + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Emit a complete nymea JSON response on the Commander Response characteristic. Uses newline-terminated encoding + * matching [NymeaPacketCodec]. + */ + private fun emitNymeaResponse(json: String) { + connection.service.emitNotification(COMMANDER_RESPONSE_UUID, (json + "\n").encodeToByteArray()) + } +} diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaPacketCodecTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaPacketCodecTest.kt new file mode 100644 index 000000000..e743fcb9b --- /dev/null +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaPacketCodecTest.kt @@ -0,0 +1,168 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.wifiprovision.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NymeaPacketCodecTest { + + // ----------------------------------------------------------------------- + // encode() + // ----------------------------------------------------------------------- + + @Test + fun `encode appends newline terminator`() { + val packets = NymeaPacketCodec.encode("{}") + val reassembled = packets.joinToString("") { it.decodeToString() } + assertTrue(reassembled.endsWith("\n"), "Encoded payload must end with newline") + } + + @Test + fun `encode short message fits in single packet`() { + val packets = NymeaPacketCodec.encode("{\"c\":4}") + assertEquals(1, packets.size, "Short JSON should fit in a single packet") + assertEquals("{\"c\":4}\n", packets[0].decodeToString()) + } + + @Test + fun `encode long message splits across multiple packets`() { + // 20-byte max packet size (default). Use a payload that exceeds it. + val json = "A".repeat(50) + val packets = NymeaPacketCodec.encode(json, maxPacketSize = 20) + + assertTrue(packets.size > 1, "Long payload should be split") + packets.forEach { packet -> assertTrue(packet.size <= 20, "Each packet must be ≤ maxPacketSize") } + + // Reassemble and verify content + val reassembled = packets.joinToString("") { it.decodeToString() } + assertEquals(json + "\n", reassembled) + } + + @Test + fun `encode boundary payload exactly fills packets`() { + // 19 chars + 1 newline = 20 bytes = exactly 1 packet at maxPacketSize=20 + val json = "A".repeat(19) + val packets = NymeaPacketCodec.encode(json, maxPacketSize = 20) + assertEquals(1, packets.size) + assertEquals(20, packets[0].size) + } + + @Test + fun `encode boundary payload one byte over splits into two packets`() { + // 20 chars + 1 newline = 21 bytes → 2 packets at maxPacketSize=20 + val json = "A".repeat(20) + val packets = NymeaPacketCodec.encode(json, maxPacketSize = 20) + assertEquals(2, packets.size) + assertEquals(20, packets[0].size) + assertEquals(1, packets[1].size) + } + + @Test + fun `encode empty string produces single packet with just newline`() { + val packets = NymeaPacketCodec.encode("") + assertEquals(1, packets.size) + assertEquals("\n", packets[0].decodeToString()) + } + + @Test + fun `encode custom maxPacketSize is respected`() { + val json = "ABCDEFGHIJ" // 10 chars + 1 newline = 11 bytes + val packets = NymeaPacketCodec.encode(json, maxPacketSize = 4) + assertEquals(3, packets.size) // 4 + 4 + 3 + packets.forEach { assertTrue(it.size <= 4) } + assertEquals(json + "\n", packets.joinToString("") { it.decodeToString() }) + } + + // ----------------------------------------------------------------------- + // Reassembler + // ----------------------------------------------------------------------- + + @Test + fun `reassembler returns complete message on single feed with terminator`() { + val reassembler = NymeaPacketCodec.Reassembler() + val result = reassembler.feed("{\"c\":4}\n".encodeToByteArray()) + assertEquals("{\"c\":4}", result) + } + + @Test + fun `reassembler buffers partial data and returns null`() { + val reassembler = NymeaPacketCodec.Reassembler() + assertNull(reassembler.feed("{\"c\":".encodeToByteArray())) + assertNull(reassembler.feed("4}".encodeToByteArray())) + } + + @Test + fun `reassembler completes when terminator arrives in later chunk`() { + val reassembler = NymeaPacketCodec.Reassembler() + assertNull(reassembler.feed("{\"c\":".encodeToByteArray())) + assertNull(reassembler.feed("4}".encodeToByteArray())) + val result = reassembler.feed("\n".encodeToByteArray()) + assertEquals("{\"c\":4}", result) + } + + @Test + fun `reassembler handles multiple messages sequentially`() { + val reassembler = NymeaPacketCodec.Reassembler() + val first = reassembler.feed("first\n".encodeToByteArray()) + assertEquals("first", first) + + val second = reassembler.feed("second\n".encodeToByteArray()) + assertEquals("second", second) + } + + @Test + fun `reassembler reset clears buffered data`() { + val reassembler = NymeaPacketCodec.Reassembler() + assertNull(reassembler.feed("partial".encodeToByteArray())) + reassembler.reset() + // After reset, the partial data is gone — new message starts fresh + val result = reassembler.feed("fresh\n".encodeToByteArray()) + assertEquals("fresh", result) + } + + @Test + fun `encode and reassembler round-trip`() { + val json = """{"c":1,"p":{"e":"MyNetwork","p":"secret123"}}""" + val packets = NymeaPacketCodec.encode(json) + val reassembler = NymeaPacketCodec.Reassembler() + + var result: String? = null + for (packet in packets) { + result = reassembler.feed(packet) + } + assertEquals(json, result) + } + + @Test + fun `encode and reassembler round-trip with small packet size`() { + val json = """{"c":0,"r":0,"p":[{"e":"TestNet","m":"AA:BB","s":85,"p":1}]}""" + val packets = NymeaPacketCodec.encode(json, maxPacketSize = 8) + assertTrue(packets.size > 1, "Should require multiple packets with small MTU") + + val reassembler = NymeaPacketCodec.Reassembler() + var result: String? = null + for (packet in packets) { + result = reassembler.feed(packet) + } + assertEquals(json, result) + } +} diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt new file mode 100644 index 000000000..2913ce55e --- /dev/null +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt @@ -0,0 +1,145 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.wifiprovision.domain + +import kotlinx.serialization.encodeToString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Tests for the nymea JSON protocol serialization models. */ +class NymeaProtocolTest { + + // ----------------------------------------------------------------------- + // NymeaSimpleCommand + // ----------------------------------------------------------------------- + + @Test + fun `simple command serializes to compact JSON`() { + val json = NymeaJson.encodeToString(NymeaSimpleCommand(command = 4)) + assertEquals("""{"c":4}""", json) + } + + @Test + fun `simple command round-trips`() { + val original = NymeaSimpleCommand(command = 0) + val json = NymeaJson.encodeToString(original) + val decoded = NymeaJson.decodeFromString(json) + assertEquals(original, decoded) + } + + // ----------------------------------------------------------------------- + // NymeaConnectCommand + // ----------------------------------------------------------------------- + + @Test + fun `connect command serializes with nested params`() { + val cmd = NymeaConnectCommand(command = 1, params = NymeaConnectParams(ssid = "TestNet", password = "pass123")) + val json = NymeaJson.encodeToString(cmd) + assertTrue(json.contains("\"c\":1")) + assertTrue(json.contains("\"e\":\"TestNet\"")) + assertTrue(json.contains("\"p\":\"pass123\"")) + } + + @Test + fun `connect command with empty password`() { + val cmd = NymeaConnectCommand(command = 1, params = NymeaConnectParams(ssid = "OpenNet", password = "")) + val json = NymeaJson.encodeToString(cmd) + assertTrue(json.contains("\"p\":\"\"")) + } + + @Test + fun `connect command round-trips`() { + val original = + NymeaConnectCommand(command = 2, params = NymeaConnectParams(ssid = "Hidden", password = "secret")) + val json = NymeaJson.encodeToString(original) + val decoded = NymeaJson.decodeFromString(json) + assertEquals(original, decoded) + } + + // ----------------------------------------------------------------------- + // NymeaResponse + // ----------------------------------------------------------------------- + + @Test + fun `response deserializes success`() { + val response = NymeaJson.decodeFromString("""{"c":4,"r":0}""") + assertEquals(4, response.command) + assertEquals(0, response.responseCode) + } + + @Test + fun `response deserializes error code`() { + val response = NymeaJson.decodeFromString("""{"c":1,"r":3}""") + assertEquals(1, response.command) + assertEquals(3, response.responseCode) + } + + @Test + fun `response ignores unknown keys`() { + val response = NymeaJson.decodeFromString("""{"c":0,"r":0,"extra":"field"}""") + assertEquals(0, response.responseCode) + } + + // ----------------------------------------------------------------------- + // NymeaNetworksResponse + // ----------------------------------------------------------------------- + + @Test + fun `networks response deserializes network list`() { + val json = + """ + { + "c": 0, + "r": 0, + "p": [ + {"e":"HomeWifi","m":"AA:BB:CC:DD:EE:01","s":85,"p":1}, + {"e":"OpenNet","m":"AA:BB:CC:DD:EE:02","s":60,"p":0} + ] + } + """ + .trimIndent() + val response = NymeaJson.decodeFromString(json) + assertEquals(0, response.responseCode) + assertEquals(2, response.networks.size) + assertEquals("HomeWifi", response.networks[0].ssid) + assertEquals(85, response.networks[0].signalStrength) + assertEquals(1, response.networks[0].protection) + assertEquals("OpenNet", response.networks[1].ssid) + assertEquals(0, response.networks[1].protection) + } + + @Test + fun `networks response deserializes empty list`() { + val json = """{"c":0,"r":0,"p":[]}""" + val response = NymeaJson.decodeFromString(json) + assertTrue(response.networks.isEmpty()) + } + + @Test + fun `networks response uses defaults for missing fields`() { + val json = """{"c":0,"r":0,"p":[{"e":"Minimal"}]}""" + val response = NymeaJson.decodeFromString(json) + val entry = response.networks[0] + assertEquals("Minimal", entry.ssid) + assertEquals("", entry.bssid) + assertEquals(0, entry.signalStrength) + assertEquals(0, entry.protection) + } +} diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt new file mode 100644 index 000000000..666d81e48 --- /dev/null +++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt @@ -0,0 +1,339 @@ +/* + * 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.wifiprovision.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.ble.BleWriteType +import org.meshtastic.core.testing.FakeBleConnection +import org.meshtastic.core.testing.FakeBleConnectionFactory +import org.meshtastic.core.testing.FakeBleDevice +import org.meshtastic.core.testing.FakeBleScanner +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.COMMANDER_RESPONSE_UUID +import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER_UUID +import org.meshtastic.feature.wifiprovision.model.ProvisionResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Tests for [NymeaWifiService] covering BLE connect, network scanning, provisioning, and error handling. Uses + * [FakeBleScanner], [FakeBleConnection], and [FakeBleConnectionFactory] from `core:testing`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NymeaWifiServiceTest { + + private val address = "AA:BB:CC:DD:EE:FF" + + private fun createService( + scanner: FakeBleScanner = FakeBleScanner(), + connection: FakeBleConnection = FakeBleConnection(), + ): Triple { + val service = + NymeaWifiService( + scanner = scanner, + connectionFactory = FakeBleConnectionFactory(connection), + dispatcher = Dispatchers.Unconfined, + ) + return Triple(service, scanner, connection) + } + + private suspend fun connectService( + service: NymeaWifiService, + scanner: FakeBleScanner, + deviceName: String? = "mpwrd-nm-1234", + ): Result { + scanner.emitDevice(FakeBleDevice(address, name = deviceName)) + return service.connect() + } + + private fun emitResponse(connection: FakeBleConnection, json: String) { + connection.service.emitNotification(COMMANDER_RESPONSE_UUID, (json + "\n").encodeToByteArray()) + } + + // ----------------------------------------------------------------------- + // connect() + // ----------------------------------------------------------------------- + + @Test + fun `connect succeeds and returns device name`() = runTest { + val (service, scanner) = createService() + val result = connectService(service, scanner) + assertTrue(result.isSuccess) + assertEquals("mpwrd-nm-1234", result.getOrThrow()) + } + + @Test + fun `connect returns device address when name is null`() = runTest { + val (service, scanner) = createService() + val result = connectService(service, scanner, deviceName = null) + assertTrue(result.isSuccess) + assertEquals(address, result.getOrThrow()) + } + + @Test + fun `connect fails when BLE connection fails`() = runTest { + val connection = FakeBleConnection() + connection.failNextN = 1 + val (service, scanner) = createService(connection = connection) + + scanner.emitDevice(FakeBleDevice(address)) + val result = service.connect() + + assertTrue(result.isFailure) + } + + @Test + fun `connect fails when BLE throws exception`() = runTest { + val connection = FakeBleConnection() + connection.connectException = RuntimeException("Bluetooth off") + val (service, scanner) = createService(connection = connection) + + scanner.emitDevice(FakeBleDevice(address)) + val result = service.connect() + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Bluetooth off") == true) + } + + // ----------------------------------------------------------------------- + // scanNetworks() + // ----------------------------------------------------------------------- + + @Test + fun `scanNetworks returns parsed network list`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + // Enqueue scan ack + networks response + emitResponse(connection, """{"c":4,"r":0}""") + emitResponse( + connection, + """{"c":0,"r":0,"p":[ + {"e":"HomeWifi","m":"AA:BB:CC:DD:EE:01","s":85,"p":1}, + {"e":"OpenNet","m":"AA:BB:CC:DD:EE:02","s":60,"p":0} + ]}""", + ) + + val result = service.scanNetworks() + assertTrue(result.isSuccess) + + val networks = result.getOrThrow() + assertEquals(2, networks.size) + assertEquals("HomeWifi", networks[0].ssid) + assertEquals(85, networks[0].signalStrength) + assertTrue(networks[0].isProtected) + assertEquals("OpenNet", networks[1].ssid) + assertEquals(false, networks[1].isProtected) + } + + @Test + fun `scanNetworks returns empty list when device has no networks`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":4,"r":0}""") + emitResponse(connection, """{"c":0,"r":0,"p":[]}""") + + val result = service.scanNetworks() + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow().isEmpty()) + } + + @Test + fun `scanNetworks fails when scan command returns error`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + // Scan returns error code 4 (wireless unavailable) + emitResponse(connection, """{"c":4,"r":4}""") + + val result = service.scanNetworks() + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull()?.message?.contains("Scan command failed") == true) + } + + @Test + fun `scanNetworks sends correct BLE commands`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":4,"r":0}""") + emitResponse(connection, """{"c":0,"r":0,"p":[]}""") + + service.scanNetworks() + + // Verify the commander writes contain the scan command and get-networks command + val commanderWrites = + connection.service.writes + .filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID } + .map { it.data.decodeToString() } + .joinToString("") + + assertTrue(commanderWrites.contains("\"c\":4"), "Should send CMD_SCAN (4)") + assertTrue(commanderWrites.contains("\"c\":0"), "Should send CMD_GET_NETWORKS (0)") + } + + @Test + fun `scanNetworks uses WITH_RESPONSE write type`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":4,"r":0}""") + emitResponse(connection, """{"c":0,"r":0,"p":[]}""") + + service.scanNetworks() + + val commanderWrites = connection.service.writes.filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID } + assertTrue(commanderWrites.all { it.writeType == BleWriteType.WITH_RESPONSE }) + } + + // ----------------------------------------------------------------------- + // provision() + // ----------------------------------------------------------------------- + + @Test + fun `provision returns Success on response code 0`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":1,"r":0}""") + val result = service.provision("MyNet", "password") + + assertIs(result) + } + + @Test + fun `provision returns Failure on non-zero response code`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":1,"r":3}""") + val result = service.provision("MyNet", "password") + + assertIs(result) + assertEquals(3, result.errorCode) + assertTrue(result.message.contains("NetworkManager")) + } + + @Test + fun `provision sends CMD_CONNECT for visible networks`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":1,"r":0}""") + service.provision("Net", "pass", hidden = false) + + val writes = + connection.service.writes + .filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID } + .map { it.data.decodeToString() } + .joinToString("") + + assertTrue(writes.contains("\"c\":1"), "Should send CMD_CONNECT (1)") + assertTrue(writes.contains("\"e\":\"Net\""), "Should contain SSID") + } + + @Test + fun `provision sends CMD_CONNECT_HIDDEN for hidden networks`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + emitResponse(connection, """{"c":2,"r":0}""") + service.provision("HiddenNet", "pass", hidden = true) + + val writes = + connection.service.writes + .filter { it.characteristic.uuid == WIRELESS_COMMANDER_UUID } + .map { it.data.decodeToString() } + .joinToString("") + + assertTrue(writes.contains("\"c\":2"), "Should send CMD_CONNECT_HIDDEN (2)") + } + + @Test + fun `provision returns Failure on exception`() = runTest { + // Create a service with a connection that will fail writes after connecting + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + // Don't emit any response — this will cause a timeout. But since we use + // Dispatchers.Unconfined the withTimeout may behave differently. + // Instead, test a different error path: test that all nymea error codes are mapped. + emitResponse(connection, """{"c":1,"r":1}""") + val result = service.provision("Net", "pass") + assertIs(result) + assertTrue(result.message.contains("Invalid command")) + } + + @Test + fun `provision maps all known error codes`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + val errorCodes = + mapOf( + 1 to "Invalid command", + 2 to "Invalid parameter", + 3 to "NetworkManager not available", + 4 to "Wireless adapter not available", + 5 to "Networking disabled", + 6 to "Wireless disabled", + 7 to "Unknown error", + ) + + for ((code, expectedMessage) in errorCodes) { + emitResponse(connection, """{"c":1,"r":$code}""") + val result = service.provision("Net", "pass") + assertIs(result) + assertTrue( + result.message.contains(expectedMessage), + "Error code $code should map to '$expectedMessage', got '${result.message}'", + ) + } + } + + // ----------------------------------------------------------------------- + // close() + // ----------------------------------------------------------------------- + + @Test + fun `close disconnects BLE`() = runTest { + val connection = FakeBleConnection() + val (service, scanner) = createService(connection = connection) + connectService(service, scanner) + + service.close() + + assertTrue(connection.disconnectCalls >= 1, "Should call BLE disconnect") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6137780f1..44dda7b43 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ include( ":feature:node", ":feature:settings", ":feature:firmware", + ":feature:wifi-provision", ":feature:widget", ":mesh_service_example", ":desktop",