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 =