From c2b482671764b548eaa61f731ee34c8d29149353 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 22 Apr 2026 21:04:16 -0500
Subject: [PATCH] feat: Enhance mPWRD-os WiFi provisioning success state and UI
components (#5225)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 15 ++
.../wifiprovision/NymeaBleConstants.kt | 6 +
.../wifiprovision/WifiProvisionViewModel.kt | 4 +
.../wifiprovision/domain/NymeaProtocol.kt | 17 ++
.../wifiprovision/domain/NymeaWifiService.kt | 28 +++-
.../wifiprovision/model/WifiNetwork.kt | 5 +-
.../wifiprovision/ui/WifiProvisionPreviews.kt | 8 +
.../wifiprovision/ui/WifiProvisionScreen.kt | 153 ++++++++++++++++++
.../WifiProvisionViewModelTest.kt | 5 +-
.../wifiprovision/domain/NymeaProtocolTest.kt | 9 ++
.../domain/NymeaWifiServiceTest.kt | 23 ++-
11 files changed, 264 insertions(+), 9 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 0e8334a4b..4021bc8aa 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1306,6 +1306,21 @@
Enter or select a network
WiFi configured successfully!
Failed to apply WiFi configuration
+ Device Connected
+ Your mPWRD-OS device has joined the Wi-Fi network.
+ IP Address
+ Complete Device Setup
+ Sign in over SSH to change the default username and password.
+ Username
+ root
+ 1234
+ SSH Command
+ ssh %1$s@%2$s
+ SSH command available after IP is assigned.
+ Open SSH Client
+ If no app opens, copy the SSH command and paste it into your SSH client.
+ IP unavailable
+ Done
Meshtastic Desktop
Show Meshtastic
Quit
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt
index 5b0d8398c..8f8051dcd 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/NymeaBleConstants.kt
@@ -70,6 +70,9 @@ internal object NymeaBleConstants {
/** Maximum time to wait for a command response. */
val RESPONSE_TIMEOUT = 15.seconds
+ /** Timeout for optional GetConnection metadata lookup after a successful connect command. */
+ val CONNECTION_INFO_TIMEOUT = 2.seconds
+
/** Settle time after subscribing to notifications before sending commands. */
val SUBSCRIPTION_SETTLE = 300.milliseconds
// endregion
@@ -87,6 +90,9 @@ internal object NymeaBleConstants {
/** Trigger a fresh WiFi scan. */
const val CMD_SCAN = 4
+
+ /** Request current connection details (includes IP address if connected). */
+ const val CMD_GET_CONNECTION = 5
// endregion
// region Response error codes
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt
index 6dbb8c676..7a146df89 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModel.kt
@@ -42,6 +42,8 @@ data class WifiProvisionUiState(
val error: WifiProvisionError? = null,
/** Name of the BLE device we connected to, shown in the DeviceFound confirmation. */
val deviceName: String? = null,
+ /** IPv4 address reported by nymea after successful provisioning (if available). */
+ val ipAddress: String? = null,
/** Provisioning outcome shown as inline status (matches web flasher pattern). */
val provisionStatus: ProvisionStatus = ProvisionStatus.Idle,
) {
@@ -175,6 +177,7 @@ class WifiProvisionViewModel(
it.copy(
phase = WifiProvisionUiState.Phase.Provisioning,
error = null,
+ ipAddress = null,
provisionStatus = WifiProvisionUiState.ProvisionStatus.Idle,
)
}
@@ -186,6 +189,7 @@ class WifiProvisionViewModel(
_uiState.update {
it.copy(
phase = WifiProvisionUiState.Phase.Connected,
+ ipAddress = result.ipAddress,
provisionStatus = WifiProvisionUiState.ProvisionStatus.Success,
)
}
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt
index 71fe68f79..846bc809a 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocol.kt
@@ -75,6 +75,8 @@ internal data class NymeaResponse(
@SerialName("c") val command: Int = -1,
/** 0 = success; non-zero = error code. */
@SerialName("r") val responseCode: Int = 0,
+ /** Optional payload (used by GetConnection and custom Connect responses). */
+ @SerialName("p") val connectionInfo: NymeaConnectionInfo? = null,
)
/** One entry in the GetNetworks (`c=0`) response payload. */
@@ -97,3 +99,18 @@ internal data class NymeaNetworksResponse(
@SerialName("r") val responseCode: Int = 0,
@SerialName("p") val networks: List = emptyList(),
)
+
+/** Connection info payload (`p`) returned by GetConnection (`c=5`). */
+@Serializable
+internal data class NymeaConnectionInfo(
+ /** ESSID / network name (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,
+ /** IPv4 address of current connection (nymea key: `i`). */
+ @SerialName("i") val ipAddress: String = "",
+)
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
index 75dc15256..f78c7323c 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
@@ -39,9 +39,11 @@ import org.meshtastic.core.common.util.safeCatching
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_CONNECTION
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.CONNECTION_INFO_TIMEOUT
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_SUCCESS
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.RESPONSE_TIMEOUT
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.SCAN_TIMEOUT
@@ -50,6 +52,7 @@ import org.meshtastic.feature.wifiprovision.NymeaBleConstants.WIRELESS_COMMANDER
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
/**
* GATT client for the nymea-networkmanager WiFi provisioning profile.
@@ -68,7 +71,6 @@ class NymeaWifiService(
connectionFactory: BleConnectionFactory,
dispatcher: CoroutineDispatcher,
) {
-
private val serviceScope = CoroutineScope(SupervisorJob() + dispatcher)
private val bleConnection = connectionFactory.create(serviceScope, TAG)
@@ -184,7 +186,9 @@ class NymeaWifiService(
sendCommand(json)
val response = NymeaJson.decodeFromString(waitForResponse())
if (response.responseCode == RESPONSE_SUCCESS) {
- ProvisionResult.Success
+ val ipAddress =
+ response.connectionInfo?.ipAddress?.takeIf { it.isNotBlank() } ?: fetchConnectionIpAddress()
+ ProvisionResult.Success(ipAddress = ipAddress)
} else {
ProvisionResult.Failure(response.responseCode, nymeaErrorMessage(response.responseCode))
}
@@ -229,7 +233,25 @@ class NymeaWifiService(
}
/** Wait up to [RESPONSE_TIMEOUT] for a complete JSON response from the notification channel. */
- private suspend fun waitForResponse(): String = withTimeout(RESPONSE_TIMEOUT) { responseChannel.receive() }
+ private suspend fun waitForResponse(timeout: Duration = RESPONSE_TIMEOUT): String =
+ withTimeout(timeout) { responseChannel.receive() }
+
+ /**
+ * Best-effort query for current connection info (`CMD_GET_CONNECTION`), returning the reported IP address.
+ *
+ * Uses a short timeout because this is an optional enrichment for UX, not a provisioning success criterion.
+ */
+ private suspend fun fetchConnectionIpAddress(): String? = safeCatching {
+ sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_GET_CONNECTION)))
+ val response =
+ NymeaJson.decodeFromString(waitForResponse(timeout = CONNECTION_INFO_TIMEOUT))
+ if (response.responseCode == RESPONSE_SUCCESS) {
+ response.connectionInfo?.ipAddress?.takeIf { it.isNotBlank() }
+ } else {
+ null
+ }
+ }
+ .getOrNull()
private fun nymeaErrorMessage(code: Int): String = when (code) {
NymeaBleConstants.RESPONSE_INVALID_COMMAND -> "Invalid command"
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt
index 50a497c5e..32dc2aa53 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/model/WifiNetwork.kt
@@ -30,7 +30,10 @@ data class WifiNetwork(
/** Result of a WiFi provisioning attempt. */
sealed interface ProvisionResult {
- data object Success : ProvisionResult
+ data class Success(
+ /** IPv4 address reported by nymea for the active Wi-Fi connection. */
+ val ipAddress: String? = null,
+ ) : ProvisionResult
data class Failure(val errorCode: Int, val message: String) : ProvisionResult
}
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt
index dc9f62f8d..fef0f0e7b 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionPreviews.kt
@@ -127,6 +127,7 @@ private fun ConnectedWithNetworksPreview() {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Idle,
+ ipAddress = null,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
@@ -145,6 +146,7 @@ private fun ConnectedEmptyNetworksPreview() {
ConnectedContent(
networks = emptyList(),
provisionStatus = ProvisionStatus.Idle,
+ ipAddress = null,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
@@ -163,6 +165,7 @@ private fun ConnectedScanningPreview() {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Idle,
+ ipAddress = null,
isProvisioning = false,
isScanning = true,
onScanNetworks = noOp,
@@ -181,6 +184,7 @@ private fun ConnectedProvisioningPreview() {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Idle,
+ ipAddress = null,
isProvisioning = true,
isScanning = false,
onScanNetworks = noOp,
@@ -199,6 +203,7 @@ private fun ConnectedSuccessPreview() {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Success,
+ ipAddress = "10.10.10.61",
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
@@ -217,6 +222,7 @@ private fun ConnectedFailedPreview() {
ConnectedContent(
networks = sampleNetworks,
provisionStatus = ProvisionStatus.Failed,
+ ipAddress = null,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
@@ -239,6 +245,7 @@ private fun ConnectedLongSsidPreview() {
ConnectedContent(
networks = edgeCaseNetworks,
provisionStatus = ProvisionStatus.Idle,
+ ipAddress = null,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
@@ -257,6 +264,7 @@ private fun ConnectedManyNetworksPreview() {
ConnectedContent(
networks = manyNetworks,
provisionStatus = ProvisionStatus.Idle,
+ ipAddress = null,
isProvisioning = false,
isScanning = false,
onScanNetworks = noOp,
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt
index 397710fea..559fd8655 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/ui/WifiProvisionScreen.kt
@@ -14,6 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@file:Suppress("TooManyFunctions", "LongMethod")
+
package org.meshtastic.feature.wifiprovision.ui
import androidx.compose.animation.AnimatedVisibility
@@ -63,6 +65,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -74,6 +77,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
@@ -82,6 +86,7 @@ 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.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.painterResource
@@ -112,15 +117,35 @@ 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_provision_success_description
+import org.meshtastic.core.resources.wifi_provision_success_device_connected
+import org.meshtastic.core.resources.wifi_provision_success_done
+import org.meshtastic.core.resources.wifi_provision_success_ip_address
+import org.meshtastic.core.resources.wifi_provision_success_missing_ip
+import org.meshtastic.core.resources.wifi_provision_success_open_ssh
+import org.meshtastic.core.resources.wifi_provision_success_open_ssh_fallback
+import org.meshtastic.core.resources.wifi_provision_success_password_value
+import org.meshtastic.core.resources.wifi_provision_success_setup_description
+import org.meshtastic.core.resources.wifi_provision_success_setup_title
+import org.meshtastic.core.resources.wifi_provision_success_ssh_command
+import org.meshtastic.core.resources.wifi_provision_success_ssh_label
+import org.meshtastic.core.resources.wifi_provision_success_ssh_unavailable
+import org.meshtastic.core.resources.wifi_provision_success_username
+import org.meshtastic.core.resources.wifi_provision_success_username_value
import org.meshtastic.core.resources.wifi_provisioning
import org.meshtastic.core.ui.component.AutoLinkText
+import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.Bluetooth
+import org.meshtastic.core.ui.icon.CheckCircle
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.Serial
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
import org.meshtastic.core.ui.icon.Wifi
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.rememberOpenUrl
import org.meshtastic.feature.wifiprovision.WifiProvisionError
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState
import org.meshtastic.feature.wifiprovision.WifiProvisionUiState.Phase
@@ -191,6 +216,7 @@ fun WifiProvisionScreen(
ConnectedContent(
networks = uiState.networks,
provisionStatus = uiState.provisionStatus,
+ ipAddress = uiState.ipAddress,
isProvisioning = uiState.phase == Phase.Provisioning,
isScanning = uiState.phase == Phase.LoadingNetworks,
onScanNetworks = viewModel::scanNetworks,
@@ -311,12 +337,18 @@ internal fun ScanningNetworksContent() {
internal fun ConnectedContent(
networks: List,
provisionStatus: ProvisionStatus,
+ ipAddress: String?,
isProvisioning: Boolean,
isScanning: Boolean,
onScanNetworks: () -> Unit,
onProvision: (ssid: String, password: String) -> Unit,
onDisconnect: () -> Unit,
) {
+ if (provisionStatus == ProvisionStatus.Success) {
+ ProvisionSuccessContent(ipAddress = ipAddress, onDone = onDisconnect)
+ return
+ }
+
var ssid by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
@@ -467,6 +499,121 @@ internal fun ConnectedContent(
}
}
+@Composable
+private fun ProvisionSuccessContent(ipAddress: String?, onDone: () -> Unit) {
+ val openUrl = rememberOpenUrl()
+ val defaultUsername = stringResource(Res.string.wifi_provision_success_username_value)
+ val defaultPassword = stringResource(Res.string.wifi_provision_success_password_value)
+ val resolvedIp = ipAddress ?: stringResource(Res.string.wifi_provision_success_missing_ip)
+ val sshCommand =
+ ipAddress?.let { stringResource(Res.string.wifi_provision_success_ssh_command, defaultUsername, it) }
+ ?: stringResource(Res.string.wifi_provision_success_ssh_unavailable)
+ val sshUri = ipAddress?.let { "ssh://$defaultUsername@$it" }
+
+ Column(
+ modifier =
+ Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(72.dp).align(Alignment.CenterHorizontally),
+ )
+ Text(
+ text = stringResource(Res.string.wifi_provision_success_device_connected),
+ style = MaterialTheme.typography.headlineMediumEmphasized,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+ Text(
+ text = stringResource(Res.string.wifi_provision_success_description),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+
+ Card(
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ ) {
+ ProvisionInfoItem(
+ label = stringResource(Res.string.wifi_provision_success_ip_address),
+ value = resolvedIp,
+ copyEnabled = ipAddress != null,
+ )
+ }
+
+ Card(
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
+ Text(
+ text = stringResource(Res.string.wifi_provision_success_setup_title),
+ style = MaterialTheme.typography.titleLargeEmphasized,
+ modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp),
+ )
+ Text(
+ text = stringResource(Res.string.wifi_provision_success_setup_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp),
+ )
+ ProvisionInfoItem(
+ label = stringResource(Res.string.wifi_provision_success_username),
+ value = defaultUsername,
+ )
+ ProvisionInfoItem(label = stringResource(Res.string.password), value = defaultPassword)
+ ProvisionInfoItem(
+ label = stringResource(Res.string.wifi_provision_success_ssh_label),
+ value = sshCommand,
+ copyEnabled = ipAddress != null,
+ )
+
+ FilledTonalButton(
+ onClick = { sshUri?.let(openUrl) },
+ enabled = sshUri != null,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 12.dp),
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.Serial,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.wifi_provision_success_open_ssh))
+ }
+ Text(
+ text = stringResource(Res.string.wifi_provision_success_open_ssh_fallback),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+ }
+
+ Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(Res.string.wifi_provision_success_done))
+ }
+ }
+}
+
+@Composable
+private fun ProvisionInfoItem(label: String, value: String, copyEnabled: Boolean = true) {
+ ListItem(
+ overlineContent = { Text(text = label, style = MaterialTheme.typography.labelLarge) },
+ headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyLargeEmphasized) },
+ trailingContent = {
+ if (copyEnabled) {
+ CopyIconButton(valueToCopy = value)
+ }
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ )
+}
+
@Composable
internal fun NetworkRow(network: WifiNetwork, isSelected: Boolean, onClick: () -> Unit) {
val containerColor =
@@ -548,3 +695,9 @@ private fun CenteredStatusContent(content: @Composable () -> Unit) {
content()
}
}
+
+@PreviewLightDark
+@Composable
+private fun ProvisionSuccessContentPreview() {
+ AppTheme { Surface { ProvisionSuccessContent(ipAddress = "192.168.1.100", onDone = {}) } }
+}
diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt
index 0ee5bb0ec..eb658c0ff 100644
--- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt
+++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/WifiProvisionViewModelTest.kt
@@ -90,6 +90,7 @@ class WifiProvisionViewModelTest {
assertTrue(state.networks.isEmpty())
assertNull(state.error)
assertNull(state.deviceName)
+ assertNull(state.ipAddress)
assertEquals(ProvisionStatus.Idle, state.provisionStatus)
}
@@ -233,12 +234,13 @@ class WifiProvisionViewModelTest {
advanceUntilIdle()
// Now provision — enqueue success response
- emitNymeaResponse("""{"c":1,"r":0}""")
+ emitNymeaResponse("""{"c":1,"r":0,"p":{"i":"10.10.10.61"}}""")
viewModel.provisionWifi("Net", "password123")
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(Phase.Connected, state.phase)
+ assertEquals("10.10.10.61", state.ipAddress)
assertEquals(ProvisionStatus.Success, state.provisionStatus)
}
@@ -305,6 +307,7 @@ class WifiProvisionViewModelTest {
assertEquals(Phase.Idle, state.phase)
assertTrue(state.networks.isEmpty())
assertNull(state.deviceName)
+ assertNull(state.ipAddress)
assertEquals(ProvisionStatus.Idle, state.provisionStatus)
}
diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt
index 2913ce55e..8f2151fb8 100644
--- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt
+++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaProtocolTest.kt
@@ -82,6 +82,7 @@ class NymeaProtocolTest {
val response = NymeaJson.decodeFromString("""{"c":4,"r":0}""")
assertEquals(4, response.command)
assertEquals(0, response.responseCode)
+ assertEquals(null, response.connectionInfo)
}
@Test
@@ -91,6 +92,14 @@ class NymeaProtocolTest {
assertEquals(3, response.responseCode)
}
+ @Test
+ fun `response deserializes connection info payload`() {
+ val response = NymeaJson.decodeFromString("""{"c":5,"r":0,"p":{"i":"10.10.10.61"}}""")
+ assertEquals(5, response.command)
+ assertEquals(0, response.responseCode)
+ assertEquals("10.10.10.61", response.connectionInfo?.ipAddress)
+ }
+
@Test
fun `response ignores unknown keys`() {
val response = NymeaJson.decodeFromString("""{"c":0,"r":0,"extra":"field"}""")
diff --git a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt
index 666d81e48..e356daa26 100644
--- a/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt
+++ b/feature/wifi-provision/src/commonTest/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiServiceTest.kt
@@ -221,10 +221,25 @@ class NymeaWifiServiceTest {
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
- emitResponse(connection, """{"c":1,"r":0}""")
+ emitResponse(connection, """{"c":1,"r":0,"p":{"i":"10.10.10.61"}}""")
val result = service.provision("MyNet", "password")
- assertIs(result)
+ val success = assertIs(result)
+ assertEquals("10.10.10.61", success.ipAddress)
+ }
+
+ @Test
+ fun `provision falls back to GetConnection for IP when connect response has no payload`() = runTest {
+ val connection = FakeBleConnection()
+ val (service, scanner) = createService(connection = connection)
+ connectService(service, scanner)
+
+ emitResponse(connection, """{"c":1,"r":0}""")
+ emitResponse(connection, """{"c":5,"r":0,"p":{"i":"10.10.10.62"}}""")
+ val result = service.provision("MyNet", "password")
+
+ val success = assertIs(result)
+ assertEquals("10.10.10.62", success.ipAddress)
}
@Test
@@ -247,7 +262,7 @@ class NymeaWifiServiceTest {
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
- emitResponse(connection, """{"c":1,"r":0}""")
+ emitResponse(connection, """{"c":1,"r":0,"p":{"i":"10.10.10.61"}}""")
service.provision("Net", "pass", hidden = false)
val writes =
@@ -266,7 +281,7 @@ class NymeaWifiServiceTest {
val (service, scanner) = createService(connection = connection)
connectService(service, scanner)
- emitResponse(connection, """{"c":2,"r":0}""")
+ emitResponse(connection, """{"c":2,"r":0,"p":{"i":"10.10.10.61"}}""")
service.provision("HiddenNet", "pass", hidden = true)
val writes =