feat(connections): Improve connection screen UI and logic (#4224)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-15 09:36:03 -06:00 committed by GitHub
parent 2f3d94c759
commit 5a59dcf2e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 199 additions and 81 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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 com.geeksville.mesh.repository.network
import android.net.ConnectivityManager
@ -47,7 +46,8 @@ constructor(
private const val SERVICE_TYPE = "_meshtastic._tcp"
fun NsdServiceInfo.toAddressString() = buildString {
append(@Suppress("DEPRECATION") host.toString().substring(1))
@Suppress("DEPRECATION")
append(host.hostAddress)
if (serviceType.trim('.') == SERVICE_TYPE && port != SERVICE_PORT) {
append(":$port")
}

View file

@ -137,6 +137,7 @@ constructor(
serviceBroadcasts.broadcastConnection()
Logger.d { "Starting connect" }
connectTimeMsec = System.currentTimeMillis()
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
startConfigOnly()
}
@ -219,6 +220,7 @@ constructor(
commandSender.sendAdmin(myNodeNum) {
setTimeOnly = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()
}
updateStatusNotification()
}
private fun reportConnection() {

View file

@ -160,6 +160,7 @@ class MeshService : Service() {
}
return if (!wantForeground) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
START_NOT_STICKY
} else {
START_STICKY

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,31 +14,27 @@
* 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 com.geeksville.mesh.ui.connections
import android.net.InetAddresses
import android.os.Build
import android.util.Patterns
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -52,8 +48,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -63,8 +57,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.ConnectingDeviceInfo
import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo
import com.geeksville.mesh.ui.connections.components.EmptyStateContent
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -81,23 +77,28 @@ import org.meshtastic.core.strings.connected_sleeping
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.connections
import org.meshtastic.core.strings.must_set_region
import org.meshtastic.core.strings.no_device_selected
import org.meshtastic.core.strings.not_connected
import org.meshtastic.core.strings.set_your_region
import org.meshtastic.core.strings.warning_not_paired
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.ConfigProtos
fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
false
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
this != null && Patterns.IP_ADDRESS.matcher(this).matches()
Patterns.IP_ADDRESS.matcher(this).matches() || Patterns.DOMAIN_NAME.matcher(this).matches()
} else {
InetAddresses.isNumericAddress(this.toString())
InetAddresses.isNumericAddress(this) || Patterns.DOMAIN_NAME.matcher(this).matches()
}
/**
@ -125,7 +126,6 @@ fun ConnectionsScreen(
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
val density = LocalDensity.current
val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
@ -197,50 +197,76 @@ fun ConnectionsScreen(
.padding(paddingValues)
.padding(16.dp),
) {
var connectionSectionHeight by remember { mutableStateOf(0.dp) }
val placeholderHeight = connectionSectionHeight.takeIf { it > 0.dp } ?: 0.dp
Box(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp).heightIn(min = placeholderHeight)) {
if (connectionState == ConnectionState.Connecting) {
Row(
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
horizontalArrangement = Arrangement.Center,
) {
CircularWavyProgressIndicator(modifier = Modifier.size(96.dp).padding(16.dp))
}
}
androidx.compose.animation.AnimatedVisibility(visible = connectionState.isConnected()) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier.fillMaxWidth().onSizeChanged { size ->
if (connectionState.isConnected()) {
connectionSectionHeight = with(density) { size.height.toDp() }
}
},
) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice =
bleDevices.firstOrNull { it.fullAddress == selectedDevice }
as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
val uiState =
when {
connectionState.isConnected() && ourNode != null -> 2
connectionState == ConnectionState.Connecting ||
(connectionState == ConnectionState.Disconnected && selectedDevice != "n") -> 1
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
) {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
else -> 0
}
Crossfade(
targetState = uiState,
label = "connection_state",
modifier = Modifier.padding(bottom = 16.dp),
) { state ->
when (state) {
2 -> {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice =
bleDevices.firstOrNull { it.fullAddress == selectedDevice }
as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
) {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
}
}
}
}
}
1 -> {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: "Unknown Device"
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(
deviceName = name,
deviceAddress = address,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
else -> {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}
}

View file

@ -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 com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.disconnect
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ConnectingDeviceInfo(
deviceName: String,
deviceAddress: String,
onClickDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularWavyProgressIndicator(modifier = Modifier.size(40.dp))
Column {
Text(text = deviceName, style = MaterialTheme.typography.titleMedium)
Text(text = deviceAddress, style = MaterialTheme.typography.bodySmall)
Text(text = stringResource(Res.string.connecting), style = MaterialTheme.typography.labelSmall)
}
}
Button(
shape = RectangleShape,
modifier = Modifier.fillMaxWidth().height(40.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.StatusRed,
contentColor = Color.White,
),
onClick = onClickDisconnect,
) {
Text(stringResource(Res.string.disconnect))
}
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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 com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
@ -39,9 +38,14 @@ import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun EmptyStateContent(imageVector: ImageVector? = null, text: String, actionButton: @Composable (() -> Unit)? = null) {
fun EmptyStateContent(
text: String,
modifier: Modifier = Modifier,
imageVector: ImageVector? = null,
actionButton: @Composable (() -> Unit)? = null,
) {
Column(
modifier = Modifier.fillMaxSize(),
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@ -63,7 +67,7 @@ fun EmptyStateContent(imageVector: ImageVector? = null, text: String, actionButt
fun EmptyStateContentPreview() {
AppTheme {
Surface {
EmptyStateContent(imageVector = Icons.Rounded.BluetoothDisabled, text = "No devices found") {
EmptyStateContent(text = "No devices found", imageVector = Icons.Rounded.BluetoothDisabled) {
Button(onClick = {}) { Text("Button") }
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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 com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
@ -52,17 +51,17 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.ui.connections.isIPAddress
import com.geeksville.mesh.ui.connections.isValidAddress
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_network_device
import org.meshtastic.core.strings.address
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.confirm_forget_connection
import org.meshtastic.core.strings.discovered_network_devices
import org.meshtastic.core.strings.forget_connection
import org.meshtastic.core.strings.ip_address
import org.meshtastic.core.strings.ip_port
import org.meshtastic.core.strings.no_network_devices
import org.meshtastic.core.strings.recent_network_devices
@ -89,8 +88,8 @@ fun NetworkDevices(
AddDeviceDialog(
searchDialogState,
onHideDialog = { showSearchDialog = false },
onClickAdd = { ipAddress, fullAddress ->
scanModel.onSelected(DeviceListEntry.Tcp(ipAddress, fullAddress))
onClickAdd = { address, fullAddress ->
scanModel.onSelected(DeviceListEntry.Tcp(address, fullAddress))
showSearchDialog = false
},
)
@ -163,9 +162,9 @@ fun NetworkDevices(
private fun AddDeviceDialog(
sheetState: SheetState,
onHideDialog: () -> Unit,
onClickAdd: (ipAddress: String, fullAddress: String) -> Unit,
onClickAdd: (address: String, fullAddress: String) -> Unit,
) {
val ipState = rememberTextFieldState("")
val addressState = rememberTextFieldState("")
val portState = rememberTextFieldState(NetworkRepository.SERVICE_PORT.toString())
val scope = rememberCoroutineScope()
@ -175,11 +174,11 @@ private fun AddDeviceDialog(
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
state = ipState,
state = addressState,
labelPosition = TextFieldLabelPosition.Above(),
lineLimits = TextFieldLineLimits.SingleLine,
label = { Text(stringResource(Res.string.ip_address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
label = { Text(stringResource(Res.string.address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
modifier = Modifier.weight(.7f),
)
@ -202,18 +201,18 @@ private fun AddDeviceDialog(
Button(
modifier = Modifier.weight(1f),
onClick = {
val ipAddress = ipState.text.toString()
if (ipAddress.isIPAddress()) {
val address = addressState.text.toString()
if (address.isValidAddress()) {
val portString = portState.text.toString()
val combinedString =
if (portString.isNotEmpty() && portString.toInt() != NetworkRepository.SERVICE_PORT) {
"$ipAddress:$portString"
"$address:$portString"
} else {
ipAddress
address
}
onClickAdd(ipState.text.toString(), "t$combinedString")
onClickAdd(addressState.text.toString(), "t$combinedString")
scope
.launch { sheetState.hide() }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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.data.datasource
import org.meshtastic.core.database.entity.MetadataEntity
@ -28,6 +27,8 @@ interface NodeInfoWriteDataSource {
suspend fun clearNodeDB(preserveFavorites: Boolean)
suspend fun clearMyNodeInfo()
suspend fun deleteNode(num: Int)
suspend fun deleteNodes(nodeNums: List<Int>)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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.data.datasource
import kotlinx.coroutines.withContext
@ -43,6 +42,9 @@ constructor(
override suspend fun clearNodeDB(preserveFavorites: Boolean) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
override suspend fun clearMyNodeInfo() =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
override suspend fun deleteNode(num: Int) =
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }

View file

@ -150,6 +150,8 @@ constructor(
suspend fun clearNodeDB(preserveFavorites: Boolean = false) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) }
suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() }
suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.deleteNode(num)
nodeInfoWriteDataSource.deleteMetadata(num)

View file

@ -217,6 +217,7 @@
<string name="ethernet_ip">Ethernet IP:</string>
<string name="connecting">Connecting</string>
<string name="not_connected">Not connected</string>
<string name="no_device_selected">No device selected</string>
<string name="connected_sleeping">Connected to radio, but it is sleeping</string>
<string name="app_too_old">Application update required</string>
<string name="must_update">You must update this application on the app store (or Github). It is too old to talk to this radio firmware. Please read our <a href="https://meshtastic.org/docs/software/android/installation">docs</a> on this topic.</string>