mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-04 14:13:47 -04:00
feat(wifi): introduce BLE-based WiFi provisioning for nymea-compatible devices (#4968)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<NavKey> {
|
||||
val address = uri.getQueryParameter("address")
|
||||
return listOf(WifiProvisionRoutes.WifiProvision(address))
|
||||
}
|
||||
|
||||
private fun routeFirmware(segments: List<String>): List<NavKey> {
|
||||
val update = if (segments.size > 1) segments[1].lowercase() == "update" else false
|
||||
return if (update) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<string name="default_mqtt_address" translatable="false">mqtt.meshtastic.org</string>
|
||||
|
||||
<string name="fallback_node_name">Meshtastic</string>
|
||||
<string name="fallback_node_name">Meshtastic %1$s</string>
|
||||
<string name="node_filter_placeholder">Filter</string>
|
||||
<string name="desc_node_filter_clear">clear node filter</string>
|
||||
<string name="node_filter_title">Filter by</string>
|
||||
@@ -186,7 +186,6 @@
|
||||
<string name="debug">Debug</string>
|
||||
|
||||
<string name="elevation_suffix" translatable="false">MSL</string>
|
||||
<string name="channel_air_util" translatable="false">ChUtil %.1f%% AirUtilTX %.1f%%</string>
|
||||
|
||||
<string name="channel">Ch</string>
|
||||
<string name="channel_name">Channel Name</string>
|
||||
@@ -405,8 +404,8 @@
|
||||
<string name="currently">Currently:</string>
|
||||
<string name="mute_status_always">Always muted</string>
|
||||
<string name="mute_status_unmuted">Not muted</string>
|
||||
<string name="mute_status_muted_for_days">Muted for %1$d days, %2$.1f hours</string>
|
||||
<string name="mute_status_muted_for_hours">Muted for %1$.1f hours</string>
|
||||
<string name="mute_status_muted_for_days">Muted for %1$d days, %2$s hours</string>
|
||||
<string name="mute_status_muted_for_hours">Muted for %1$s hours</string>
|
||||
<string name="mute_status_label">Mute status</string>
|
||||
<string name="mute_add">Mute notifications for '%1$s'?</string>
|
||||
<string name="mute_remove">Unmute notifications for '%1$s'?</string>
|
||||
@@ -504,7 +503,7 @@
|
||||
<string name="are_you_sure">Are you sure?</string>
|
||||
<string name="router_role_confirmation_text"><![CDATA[I have read the <a href="https://meshtastic.org/docs/configuration/radio/device/#roles">Device Role Documentation</a> and the blog post about <a href="http://meshtastic.org/blog/choosing-the-right-device-role">Choosing The Right Device Role</a>.]]></string>
|
||||
<string name="i_know_what_i_m_doing">I know what I'm doing.</string>
|
||||
<string name="low_battery_message">Node %1$s has a low battery (%2$d%%)</string>
|
||||
<string name="low_battery_message">Node %1$s has a low battery (%2$d%)</string>
|
||||
<string name="meshtastic_low_battery_notifications">Low battery notifications</string>
|
||||
<string name="low_battery_title">Low battery: %1$s</string>
|
||||
<string name="meshtastic_low_battery_temporary_remote_notifications">Low battery notifications (favorite nodes)</string>
|
||||
@@ -1081,7 +1080,7 @@
|
||||
<string name="firmware_update_stable">Stable</string>
|
||||
<string name="firmware_update_alpha">Alpha</string>
|
||||
<string name="firmware_update_disconnect_warning">Note: This will temporarily disconnect your device during the update.</string>
|
||||
<string name="firmware_update_downloading_percent">Downloading firmware... %1$d%%</string>
|
||||
<string name="firmware_update_downloading_percent">Downloading firmware... %1$d%</string>
|
||||
<string name="firmware_update_error">Error: %1$s</string>
|
||||
<string name="firmware_update_retry">Retry</string>
|
||||
<string name="firmware_update_success">Update Successful!</string>
|
||||
@@ -1132,7 +1131,7 @@
|
||||
<string name="firmware_update_dfu_error">DFU Error: %1$s</string>
|
||||
<string name="firmware_update_dfu_aborted">DFU Aborted</string>
|
||||
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
|
||||
<string name="firmware_update_battery_low">Battery too low (%1$d%%). Please charge your device before updating.</string>
|
||||
<string name="firmware_update_battery_low">Battery too low (%1$d%). Please charge your device before updating.</string>
|
||||
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
|
||||
<string name="firmware_update_nordic_failed">Nordic DFU Update failed</string>
|
||||
<string name="firmware_update_usb_failed">USB Update failed</string>
|
||||
@@ -1144,7 +1143,7 @@
|
||||
<string name="firmware_update_checking_version">Checking device version...</string>
|
||||
<string name="firmware_update_starting_ota">Starting OTA update...</string>
|
||||
<string name="firmware_update_uploading">Uploading firmware...</string>
|
||||
<string name="firmware_update_uploading_progress">Uploading firmware... %1$d%% (%2$s)</string>
|
||||
<string name="firmware_update_uploading_progress">Uploading firmware... %1$d% (%2$s)</string>
|
||||
<string name="firmware_update_rebooting_device">Rebooting device...</string>
|
||||
<string name="firmware_update_channel_name">Firmware Update</string>
|
||||
<string name="firmware_update_channel_description">Firmware update status</string>
|
||||
@@ -1230,10 +1229,10 @@
|
||||
|
||||
<string name="map_style_selection">Map style selection</string>
|
||||
|
||||
<string name="local_stats_battery">Battery: %1$d%%</string>
|
||||
<string name="local_stats_battery">Battery: %1$d%</string>
|
||||
<string name="local_stats_nodes">Nodes: %1$d online / %2$d total</string>
|
||||
<string name="local_stats_uptime">Uptime: %1$s</string>
|
||||
<string name="local_stats_utilization">ChUtil: %1$.2f%% | AirTX: %2$.2f%%</string>
|
||||
<string name="local_stats_utilization">ChUtil: %1$s% | AirTX: %2$s%</string>
|
||||
<string name="local_stats_traffic">Traffic: TX %1$d / RX %2$d (D: %3$d)</string>
|
||||
<string name="local_stats_relays">Relays: %1$d (Canceled: %2$d)</string>
|
||||
<string name="local_stats_diagnostics_prefix">Diagnostics: %1$s</string>
|
||||
@@ -1325,4 +1324,28 @@
|
||||
<string name="files_available">Files available (%1$d):</string>
|
||||
<string name="file_entry">- %1$s (%2$d bytes)</string>
|
||||
<string name="no_files_manifested">No files manifested.</string>
|
||||
|
||||
<string name="connect">Connect</string>
|
||||
<string name="done">Done</string>
|
||||
<string name="wifi_provisioning">WiFi Provisioning</string>
|
||||
<string name="wifi_provision_description">Provision WiFi credentials to your Meshtastic device via Bluetooth.</string>
|
||||
<string name="wifi_provision_scanning_ble">Searching for device…</string>
|
||||
<string name="wifi_provision_device_found">Device found</string>
|
||||
<string name="wifi_provision_device_found_detail">Ready to scan for WiFi networks.</string>
|
||||
<string name="wifi_provision_scan_networks">Scan for Networks</string>
|
||||
<string name="wifi_provision_scanning_wifi">Scanning…</string>
|
||||
<string name="wifi_provision_sending_credentials">Applying WiFi configuration…</string>
|
||||
<string name="wifi_provision_success">WiFi configured successfully!</string>
|
||||
<string name="wifi_provision_success_detail">WiFi credentials applied. The device will connect to the network shortly.</string>
|
||||
<string name="wifi_provision_no_networks">No networks found</string>
|
||||
<string name="wifi_provision_no_networks_detail">Make sure the device is powered on and within range.</string>
|
||||
<string name="wifi_provision_connect_failed">Could not connect: %1$s</string>
|
||||
<string name="wifi_provision_scan_failed">Failed to scan for WiFi networks: %1$s</string>
|
||||
<string name="wifi_provision_refresh">Refresh</string>
|
||||
<string name="wifi_provision_signal_strength">%1$d%</string>
|
||||
<string name="wifi_provision_available_networks">Available Networks</string>
|
||||
<string name="wifi_provision_ssid_label">Network Name (SSID)</string>
|
||||
<string name="wifi_provision_ssid_placeholder">Enter or select a network</string>
|
||||
<string name="wifi_provision_status_applied">WiFi configured successfully!</string>
|
||||
<string name="wifi_provision_status_failed">Failed to apply WiFi configuration</string>
|
||||
</resources>
|
||||
|
||||
@@ -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<String>()
|
||||
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<String>()
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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<NavKey>.desktopNavGraph(
|
||||
|
||||
// Connections — shared screen
|
||||
connectionsGraph(backStack)
|
||||
|
||||
// WiFi Provisioning — nymea-networkmanager BLE protocol
|
||||
wifiProvisionGraph(backStack)
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) },
|
||||
|
||||
67
feature/wifi-provision/README.md
Normal file
67
feature/wifi-provision/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# `:feature:wifi-provision`
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
<!--region 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;
|
||||
|
||||
```
|
||||
<!--endregion-->
|
||||
|
||||
## 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.
|
||||
41
feature/wifi-provision/build.gradle.kts
Normal file
41
feature/wifi-provision/build.gradle.kts
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<WifiNetwork> = 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<WifiProvisionUiState> = _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<WifiNetwork>): List<WifiNetwork> = networks
|
||||
.groupBy { it.ssid }
|
||||
.map { (_, entries) -> entries.maxBy { it.signalStrength } }
|
||||
.sortedByDescending { it.signalStrength }
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<ByteArray> {
|
||||
val payload = (json + STREAM_TERMINATOR).encodeToByteArray()
|
||||
val packets = mutableListOf<ByteArray>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<NymeaNetworkEntry> = emptyList(),
|
||||
)
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>(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<String> = 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<Unit>()
|
||||
|
||||
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<List<WifiNetwork>> = runCatching {
|
||||
// Trigger scan
|
||||
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN)))
|
||||
val scanAck = NymeaJson.decodeFromString<NymeaResponse>(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<NymeaNetworksResponse>(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<NymeaResponse>(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"
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<NavKey>.wifiProvisionGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<WifiProvisionRoutes.WifiProvisionGraph> {
|
||||
WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
entry<WifiProvisionRoutes.WifiProvision> { key ->
|
||||
WifiProvisionScreen(onNavigateUp = { backStack.removeLastOrNull() }, address = key.address)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Color, Color> = 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 -> ""
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<WifiNetwork>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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")
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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<WifiProvisionError.ConnectFailed>(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<WifiProvisionError.ConnectFailed>(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<WifiProvisionError.ProvisionFailed>(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())
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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<NymeaSimpleCommand>(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<NymeaConnectCommand>(json)
|
||||
assertEquals(original, decoded)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// NymeaResponse
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `response deserializes success`() {
|
||||
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"c":4,"r":0}""")
|
||||
assertEquals(4, response.command)
|
||||
assertEquals(0, response.responseCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response deserializes error code`() {
|
||||
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"c":1,"r":3}""")
|
||||
assertEquals(1, response.command)
|
||||
assertEquals(3, response.responseCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response ignores unknown keys`() {
|
||||
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"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<NymeaNetworksResponse>(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<NymeaNetworksResponse>(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<NymeaNetworksResponse>(json)
|
||||
val entry = response.networks[0]
|
||||
assertEquals("Minimal", entry.ssid)
|
||||
assertEquals("", entry.bssid)
|
||||
assertEquals(0, entry.signalStrength)
|
||||
assertEquals(0, entry.protection)
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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<NymeaWifiService, FakeBleScanner, FakeBleConnection> {
|
||||
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<String> {
|
||||
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<ProvisionResult.Success>(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<ProvisionResult.Failure>(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<ProvisionResult.Failure>(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<ProvisionResult.Failure>(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")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ include(
|
||||
":feature:node",
|
||||
":feature:settings",
|
||||
":feature:firmware",
|
||||
":feature:wifi-provision",
|
||||
":feature:widget",
|
||||
":mesh_service_example",
|
||||
":desktop",
|
||||
|
||||
Reference in New Issue
Block a user