mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-18 11:46:28 -04:00
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>
This commit is contained in:
@@ -1306,6 +1306,21 @@
|
||||
<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>
|
||||
<string name="wifi_provision_success_device_connected">Device Connected</string>
|
||||
<string name="wifi_provision_success_description">Your mPWRD-OS device has joined the Wi-Fi network.</string>
|
||||
<string name="wifi_provision_success_ip_address">IP Address</string>
|
||||
<string name="wifi_provision_success_setup_title">Complete Device Setup</string>
|
||||
<string name="wifi_provision_success_setup_description">Sign in over SSH to change the default username and password.</string>
|
||||
<string name="wifi_provision_success_username">Username</string>
|
||||
<string name="wifi_provision_success_username_value" translatable="false">root</string>
|
||||
<string name="wifi_provision_success_password_value" translatable="false">1234</string>
|
||||
<string name="wifi_provision_success_ssh_label">SSH Command</string>
|
||||
<string name="wifi_provision_success_ssh_command">ssh %1$s@%2$s</string>
|
||||
<string name="wifi_provision_success_ssh_unavailable">SSH command available after IP is assigned.</string>
|
||||
<string name="wifi_provision_success_open_ssh">Open SSH Client</string>
|
||||
<string name="wifi_provision_success_open_ssh_fallback">If no app opens, copy the SSH command and paste it into your SSH client.</string>
|
||||
<string name="wifi_provision_success_missing_ip">IP unavailable</string>
|
||||
<string name="wifi_provision_success_done">Done</string>
|
||||
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
|
||||
<string name="desktop_tray_show">Show Meshtastic</string>
|
||||
<string name="desktop_tray_quit">Quit</string>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<NymeaNetworkEntry> = 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 = "",
|
||||
)
|
||||
|
||||
@@ -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<NymeaResponse>(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<NymeaResponse>(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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* 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", "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<WifiNetwork>,
|
||||
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 = {}) } }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class NymeaProtocolTest {
|
||||
val response = NymeaJson.decodeFromString<NymeaResponse>("""{"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<NymeaResponse>("""{"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<NymeaResponse>("""{"c":0,"r":0,"extra":"field"}""")
|
||||
|
||||
@@ -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<ProvisionResult.Success>(result)
|
||||
val success = assertIs<ProvisionResult.Success>(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<ProvisionResult.Success>(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 =
|
||||
|
||||
Reference in New Issue
Block a user