diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
index d45fa0dbe..bacf17c57 100644
--- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt
@@ -42,6 +42,7 @@ import com.hoho.android.usbserial.driver.UsbSerialDriver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
@@ -49,7 +50,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@@ -127,6 +127,9 @@ class BTScanModel @Inject constructor(
val isBLE: Boolean get() = prefix == 'x'
val isUSB: Boolean get() = prefix == 's'
val isTCP: Boolean get() = prefix == 't'
+
+ val isMock: Boolean get() = prefix == 'm'
+ val isDisconnect: Boolean get() = prefix == 'n'
}
@SuppressLint("MissingPermission")
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt
index 4b0b3831b..23ffc7cb8 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt
@@ -39,21 +39,25 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
-import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.RadioButton
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -70,7 +74,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -92,15 +95,16 @@ import com.geeksville.mesh.android.isGooglePlayAvailable
import com.geeksville.mesh.android.permissionMissing
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.BluetoothViewModel
-import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.getNavRouteFrom
-import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.service.MeshService
+import com.geeksville.mesh.ui.connections.components.BLEDevices
+import com.geeksville.mesh.ui.connections.components.NetworkDevices
+import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.geeksville.mesh.ui.node.NodeActionButton
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
@@ -186,10 +190,6 @@ fun ConnectionsScreen(
}
}
- // State for manual IP address input
- var manualIpAddress by remember { mutableStateOf("") }
- var manualIpPort by remember { mutableStateOf(NetworkRepository.SERVICE_PORT.toString()) }
-
// State for the device scan dialog
var showScanDialog by remember { mutableStateOf(false) }
val scanResults by scanModel.scanResult.observeAsState(emptyMap())
@@ -267,11 +267,9 @@ fun ConnectionsScreen(
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
- .fillMaxSize()
- .padding(16.dp)
- .verticalScroll(scrollState)
+ .fillMaxWidth()
+ .padding(8.dp)
) {
- // Scan Status Text
Text(
text = scanStatusText.orEmpty(),
fontSize = 14.sp,
@@ -286,6 +284,7 @@ fun ConnectionsScreen(
if (isConnected) {
ourNode?.let { node ->
Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
NodeChip(
@@ -308,17 +307,37 @@ fun ConnectionsScreen(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
+ modifier = Modifier.weight(1f, fill = true),
text = node.user.longName,
style = MaterialTheme.typography.titleLarge
)
+ IconButton(
+ enabled = true,
+ onClick = onNavigateToRadioConfig
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = stringResource(id = R.string.radio_configuration)
+ )
+ }
+ FilledIconButton(
+ colors = IconButtonDefaults.filledIconButtonColors().copy(
+ containerColor = MaterialTheme.colorScheme.error
+ ),
+ enabled = true,
+ onClick = {
+ devices.values.find { it.isDisconnect }?.let {
+ scanModel.onSelected(it)
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.CloudOff,
+ contentDescription = stringResource(id = R.string.disconnect),
+ )
+ }
}
}
- NodeActionButton(
- title = stringResource(id = R.string.radio_configuration),
- icon = Icons.Default.Settings,
- enabled = true,
- onClick = onNavigateToRadioConfig
- )
Spacer(modifier = Modifier.height(8.dp))
if (regionUnset && selectedDevice != "m") {
NodeActionButton(
@@ -331,394 +350,354 @@ fun ConnectionsScreen(
}
)
Spacer(modifier = Modifier.height(8.dp))
+ if (scanning) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
}
}
- // Device List and Manual Input
- Text(
- text = stringResource(R.string.device),
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.padding(vertical = 8.dp)
- )
-
- // Progress bar while scanning
- if (scanning) {
- LinearProgressIndicator(
- modifier = Modifier.fillMaxWidth()
+ var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) }
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.itemShape(
+ DeviceType.BLE.ordinal,
+ DeviceType.entries.size
+ ),
+ onClick = {
+ selectedDeviceType = DeviceType.BLE
+ },
+ selected = (selectedDeviceType == DeviceType.BLE),
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Bluetooth,
+ contentDescription = stringResource(id = R.string.bluetooth)
+ )
+ },
+ label = {
+ Text(text = stringResource(id = R.string.bluetooth))
+ }
+ )
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.itemShape(
+ DeviceType.TCP.ordinal,
+ DeviceType.entries.size
+ ),
+ onClick = {
+ selectedDeviceType = DeviceType.TCP
+ },
+ selected = (selectedDeviceType == DeviceType.TCP),
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Wifi,
+ contentDescription = stringResource(id = R.string.network)
+ )
+ },
+ label = {
+ Text(text = stringResource(id = R.string.network))
+ }
+ )
+ SegmentedButton(
+ shape = SegmentedButtonDefaults.itemShape(
+ DeviceType.USB.ordinal,
+ DeviceType.entries.size
+ ),
+ onClick = {
+ selectedDeviceType = DeviceType.USB
+ },
+ selected = (selectedDeviceType == DeviceType.USB),
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Usb,
+ contentDescription = stringResource(id = R.string.serial)
+ )
+ },
+ label = {
+ Text(text = stringResource(id = R.string.serial))
+ }
)
}
- Column(modifier = Modifier.selectableGroup()) {
- devices.values.forEach { device ->
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ .verticalScroll(scrollState)
+ ) {
+ when (selectedDeviceType) {
+ DeviceType.BLE -> {
+
+ BLEDevices(
+ devices.values.filter { it.isBLE },
+ selectedDevice,
+ showBluetoothRationaleDialog = {
+ showBluetoothRationaleDialog = true
+ },
+ requestBluetoothPermission = {
+ requestBluetoothPermissionLauncher.launch(
+ it
+ )
+ },
+ scanModel
+ )
+ }
+
+ DeviceType.TCP -> {
+ NetworkDevices(
+ devices.values.filter { it.isTCP },
+ selectedDevice,
+ scanModel
+ )
+ }
+
+ DeviceType.USB -> {
+ UsbDevices(
+ devices.values.filter { it.isUSB || it.isMock },
+ selectedDevice,
+ scanModel
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ LaunchedEffect(ourNode) {
+ if (ourNode != null) {
+ uiViewModel.refreshProvideLocation()
+ }
+ }
+ AnimatedVisibility(isConnected) {
+ val provideLocation by uiViewModel.provideLocation.collectAsState(false)
+ LaunchedEffect(provideLocation) {
+ if (provideLocation) {
+ if (!context.hasLocationPermission()) {
+ debug("Requesting location permission for providing location")
+ showLocationRationaleDialog = true
+ } else if (isGpsDisabled) {
+ uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
+ }
+ }
+ }
Row(
modifier = Modifier
.fillMaxWidth()
- .selectable(
- selected = (device.fullAddress == selectedDevice) ||
- device.fullAddress == NO_DEVICE_SELECTED,
- onClick = {
- if (!device.bonded) {
- uiViewModel.showSnackbar(context.getString(R.string.starting_pairing))
- }
- scanModel.onSelected(device)
+ .toggleable(
+ value = provideLocation,
+ onValueChange = { checked ->
+ uiViewModel.setProvideLocation(checked)
},
- role = Role.RadioButton,
+ enabled = !isGpsDisabled
)
- .padding(8.dp),
+ .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
- RadioButton(
- selected = (device.fullAddress == selectedDevice),
- onClick = null
+ Checkbox(
+ checked = receivingLocationUpdates,
+ onCheckedChange = null,
+ enabled = !isGpsDisabled // Disable if GPS is disabled
)
Text(
- text = device.name,
+ text = stringResource(R.string.provide_location_to_mesh),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
+ // Provide Location Checkbox
- // Manual IP Address Input
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .selectable(
- selected = ("t$manualIpAddress:$manualIpPort" == selectedDevice),
- onClick = {
- if (manualIpAddress.isIPAddress()) {
- scanModel.onSelected(
- BTScanModel.DeviceListEntry(
- "",
- "t$manualIpAddress:$manualIpPort",
- true
- )
- )
- }
- },
- enabled = manualIpAddress.isIPAddress(),
- role = Role.RadioButton
- )
- .padding(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- RadioButton(
- selected = ("t$manualIpAddress:$manualIpPort" == selectedDevice),
- onClick = null,
- enabled = manualIpAddress.isIPAddress()
- )
- OutlinedTextField(
- value = manualIpAddress,
- onValueChange = { manualIpAddress = it },
- label = { Text(stringResource(R.string.ip_address)) },
- singleLine = true,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Number,
- imeAction = androidx.compose.ui.text.input.ImeAction.Done
- ),
- keyboardActions = KeyboardActions {
- if (manualIpAddress.isIPAddress()) {
- scanModel.onSelected(
- BTScanModel.DeviceListEntry(
- "",
- "t$manualIpAddress:$manualIpPort",
- true
- )
- )
- }
- },
- modifier = Modifier
- .weight(0.7f)
- .padding(start = 16.dp)
- )
- OutlinedTextField(
- value = manualIpPort,
- onValueChange = {
- // Only allow numeric input for port
- if (it.all { char -> char.isDigit() }) {
- manualIpPort = it
- }
- },
- label = { Text(stringResource(R.string.ip_port)) },
- singleLine = true,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Number,
- imeAction = androidx.compose.ui.text.input.ImeAction.Done
- ),
- keyboardActions = KeyboardActions {
- if (manualIpAddress.isIPAddress()) {
- scanModel.onSelected(
- BTScanModel.DeviceListEntry(
- "",
- "t$manualIpAddress:$manualIpPort",
- true
- )
- )
- }
- },
- modifier = Modifier
- .weight(weight = 0.3f)
- .padding(start = 8.dp)
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Warning Not Paired
+ val showWarningNotPaired = !devices.any { it.value.bonded }
+ if (showWarningNotPaired) {
+ Text(
+ text = stringResource(R.string.warning_not_paired),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(horizontal = 16.dp)
)
+ Spacer(modifier = Modifier.height(16.dp))
}
- }
- Spacer(modifier = Modifier.height(16.dp))
+ // Analytics Okay Checkbox
- LaunchedEffect(ourNode) {
- if (ourNode != null) {
- uiViewModel.refreshProvideLocation()
- }
- }
- AnimatedVisibility(isConnected) {
- val provideLocation by uiViewModel.provideLocation.collectAsState(false)
- LaunchedEffect(provideLocation) {
- if (provideLocation) {
- if (!context.hasLocationPermission()) {
- debug("Requesting location permission for providing location")
- showLocationRationaleDialog = true
- } else if (isGpsDisabled) {
- uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
- }
+ val isGooglePlayAvailable = app.isGooglePlayAvailable()
+ val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
+ if (isGooglePlayAvailable) {
+ var loading by remember { mutableStateOf(false) }
+ LaunchedEffect(isAnalyticsAllowed) {
+ loading = false
}
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .toggleable(
- value = provideLocation,
- onValueChange = { checked ->
- uiViewModel.setProvideLocation(checked)
- },
- enabled = !isGpsDisabled
- )
- .padding(horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Checkbox(
- checked = receivingLocationUpdates,
- onCheckedChange = null,
- enabled = !isGpsDisabled // Disable if GPS is disabled
- )
- Text(
- text = stringResource(R.string.provide_location_to_mesh),
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(start = 16.dp)
- )
- }
- }
- // Provide Location Checkbox
-
- Spacer(modifier = Modifier.height(16.dp))
-
- // Warning Not Paired
- val showWarningNotPaired = !devices.any { it.value.bonded }
- if (showWarningNotPaired) {
- Text(
- text = stringResource(R.string.warning_not_paired),
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(horizontal = 16.dp)
- )
- Spacer(modifier = Modifier.height(16.dp))
- }
-
- // Analytics Okay Checkbox
-
- val isGooglePlayAvailable = app.isGooglePlayAvailable()
- val isAnalyticsAllowed = app.isAnalyticsAllowed && isGooglePlayAvailable
- if (isGooglePlayAvailable) {
- var loading by remember { mutableStateOf(false) }
- LaunchedEffect(isAnalyticsAllowed) {
- loading = false
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .toggleable(
- value = isAnalyticsAllowed,
- onValueChange = {
- debug("User changed analytics to $it")
- app.isAnalyticsAllowed = it
- loading = true
- },
- role = Role.Checkbox,
- enabled = isGooglePlayAvailable && !loading
- )
- .padding(horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Checkbox(
- enabled = isGooglePlayAvailable,
- checked = isAnalyticsAllowed,
- onCheckedChange = null
- )
- Text(
- text = stringResource(R.string.analytics_okay),
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(start = 16.dp)
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
- // Report Bug Button
- Button(
- onClick = { showReportBugDialog = true }, // Set state to show Report Bug dialog
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- enabled = isAnalyticsAllowed
- ) {
- Text(stringResource(R.string.report_bug))
- }
- }
- }
- // Floating Action Button (Change Radio)
- FloatingActionButton(
- onClick = {
- val bluetoothPermissions = context.getBluetoothPermissions()
- if (bluetoothPermissions.isEmpty()) {
- // If no permissions needed, trigger the scan directly (or via ViewModel)
- scanModel.startScan()
- } else {
- if (
- context.findActivity()
- .shouldShowRequestPermissionRationale(bluetoothPermissions.first())
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .toggleable(
+ value = isAnalyticsAllowed,
+ onValueChange = {
+ debug("User changed analytics to $it")
+ app.isAnalyticsAllowed = it
+ loading = true
+ },
+ role = Role.Checkbox,
+ enabled = isGooglePlayAvailable && !loading
+ )
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- showBluetoothRationaleDialog = true
- } else {
- requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
- }
- }
- },
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(16.dp)
- ) {
- Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.change_radio))
- }
- }
-
-// Compose Device Scan Dialog
- if (showScanDialog) {
- Dialog(onDismissRequest = {
- showScanDialog = false
- scanModel.clearScanResults()
- }) {
- Surface(shape = MaterialTheme.shapes.medium) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = "Select a Bluetooth device",
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.padding(bottom = 16.dp)
- )
- Column(modifier = Modifier.selectableGroup()) {
- scanResults.values.forEach { device ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .selectable(
- selected = false, // No pre-selection in this dialog
- onClick = {
- scanModel.onSelected(device)
- scanModel.clearScanResults()
- showScanDialog = false
- }
- )
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(text = device.name)
- }
- }
+ Checkbox(
+ enabled = isGooglePlayAvailable,
+ checked = isAnalyticsAllowed,
+ onCheckedChange = null
+ )
+ Text(
+ text = stringResource(R.string.analytics_okay),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(start = 16.dp)
+ )
}
Spacer(modifier = Modifier.height(16.dp))
- TextButton(onClick = {
- scanModel.clearScanResults()
- showScanDialog = false
+ // Report Bug Button
+ Button(
+ onClick = {
+ showReportBugDialog = true
+ }, // Set state to show Report Bug dialog
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ enabled = isAnalyticsAllowed
+ ) {
+ Text(stringResource(R.string.report_bug))
+ }
+ }
+ }
+ }
+
+// Compose Device Scan Dialog
+ if (showScanDialog) {
+ Dialog(onDismissRequest = {
+ showScanDialog = false
+ scanModel.clearScanResults()
+ }) {
+ Surface(shape = MaterialTheme.shapes.medium) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(
+ text = "Select a Bluetooth device",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ Column(modifier = Modifier.selectableGroup()) {
+ scanResults.values.forEach { device ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = false, // No pre-selection in this dialog
+ onClick = {
+ scanModel.onSelected(device)
+ scanModel.clearScanResults()
+ showScanDialog = false
+ }
+ )
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(text = device.name)
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TextButton(onClick = {
+ scanModel.clearScanResults()
+ showScanDialog = false
+ }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ }
+ }
+ }
+
+// Compose Location Permission Rationale Dialog
+ if (showLocationRationaleDialog) {
+ AlertDialog(
+ onDismissRequest = { showLocationRationaleDialog = false },
+ title = { Text(stringResource(R.string.background_required)) },
+ text = { Text(stringResource(R.string.why_background_required)) },
+ confirmButton = {
+ Button(onClick = {
+ showLocationRationaleDialog = false
+ if (!context.hasLocationPermission()) {
+ requestLocationPermissionLauncher.launch(context.getLocationPermissions())
+ }
+ }) {
+ Text(stringResource(R.string.accept))
+ }
+ },
+ dismissButton = {
+ Button(onClick = { showLocationRationaleDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+
+// Compose Bluetooth Permission Rationale Dialog
+ if (showBluetoothRationaleDialog) {
+ val bluetoothPermissions = context.getBluetoothPermissions()
+ AlertDialog(
+ onDismissRequest = { showBluetoothRationaleDialog = false },
+ title = { Text(stringResource(R.string.required_permissions)) },
+ text = { Text(stringResource(R.string.permission_missing_31)) },
+ confirmButton = {
+ Button(onClick = {
+ showBluetoothRationaleDialog = false
+ if (bluetoothPermissions.isNotEmpty()) {
+ requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
+ } else {
+ // If somehow no permissions are required, just scan
+ scanModel.startScan()
+ }
+ }) {
+ Text(stringResource(R.string.okay))
+ }
+ },
+ dismissButton = {
+ Button(onClick = { showBluetoothRationaleDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+
+// Compose Report Bug Dialog
+ if (showReportBugDialog) {
+ AlertDialog(
+ onDismissRequest = { showReportBugDialog = false },
+ title = { Text(stringResource(R.string.report_a_bug)) },
+ text = { Text(stringResource(R.string.report_bug_text)) },
+ confirmButton = {
+ Button(onClick = {
+ showReportBugDialog = false
+ reportError("Clicked Report A Bug")
+ uiViewModel.showSnackbar("Bug report sent!")
+ }) {
+ Text(stringResource(R.string.report))
+ }
+ },
+ dismissButton = {
+ Button(onClick = {
+ showReportBugDialog = false
+ debug("Decided not to report a bug")
}) {
Text(stringResource(R.string.cancel))
}
}
- }
+ )
}
}
-
-// Compose Location Permission Rationale Dialog
- if (showLocationRationaleDialog) {
- AlertDialog(
- onDismissRequest = { showLocationRationaleDialog = false },
- title = { Text(stringResource(R.string.background_required)) },
- text = { Text(stringResource(R.string.why_background_required)) },
- confirmButton = {
- Button(onClick = {
- showLocationRationaleDialog = false
- if (!context.hasLocationPermission()) {
- requestLocationPermissionLauncher.launch(context.getLocationPermissions())
- }
- }) {
- Text(stringResource(R.string.accept))
- }
- },
- dismissButton = {
- Button(onClick = { showLocationRationaleDialog = false }) {
- Text(stringResource(R.string.cancel))
- }
- }
- )
- }
-
-// Compose Bluetooth Permission Rationale Dialog
- if (showBluetoothRationaleDialog) {
- val bluetoothPermissions = context.getBluetoothPermissions()
- AlertDialog(
- onDismissRequest = { showBluetoothRationaleDialog = false },
- title = { Text(stringResource(R.string.required_permissions)) },
- text = { Text(stringResource(R.string.permission_missing_31)) },
- confirmButton = {
- Button(onClick = {
- showBluetoothRationaleDialog = false
- if (bluetoothPermissions.isNotEmpty()) {
- requestBluetoothPermissionLauncher.launch(bluetoothPermissions)
- } else {
- // If somehow no permissions are required, just scan
- scanModel.startScan()
- }
- }) {
- Text(stringResource(R.string.okay))
- }
- },
- dismissButton = {
- Button(onClick = { showBluetoothRationaleDialog = false }) {
- Text(stringResource(R.string.cancel))
- }
- }
- )
- }
-
-// Compose Report Bug Dialog
- if (showReportBugDialog) {
- AlertDialog(
- onDismissRequest = { showReportBugDialog = false },
- title = { Text(stringResource(R.string.report_a_bug)) },
- text = { Text(stringResource(R.string.report_bug_text)) },
- confirmButton = {
- Button(onClick = {
- showReportBugDialog = false
- reportError("Clicked Report A Bug")
- uiViewModel.showSnackbar("Bug report sent!")
- }) {
- Text(stringResource(R.string.report))
- }
- },
- dismissButton = {
- Button(onClick = {
- showReportBugDialog = false
- debug("Decided not to report a bug")
- }) {
- Text(stringResource(R.string.cancel))
- }
- }
- )
- }
}
private tailrec fun Context.findActivity(): Activity = when (this) {
@@ -727,4 +706,10 @@ private tailrec fun Context.findActivity(): Activity = when (this) {
else -> error("No activity found")
}
+private enum class DeviceType {
+ BLE,
+ TCP,
+ USB,
+}
+
private const val SCAN_PERIOD: Long = 10000 // 10 seconds
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
new file mode 100644
index 000000000..a96ef52f5
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2025 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 .
+ */
+
+package com.geeksville.mesh.ui.connections.components
+
+import android.app.Activity
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.BluetoothDisabled
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.app.ActivityCompat
+import com.geeksville.mesh.R
+import com.geeksville.mesh.android.getBluetoothPermissions
+import com.geeksville.mesh.model.BTScanModel
+
+@Suppress("LongMethod")
+@Composable
+fun BLEDevices(
+ btDevices: List,
+ selectedDevice: String,
+ showBluetoothRationaleDialog: () -> Unit,
+ requestBluetoothPermission: (Array) -> Unit,
+ scanModel: BTScanModel
+) {
+ val context = LocalContext.current
+ Row {
+ Text(
+ text = stringResource(R.string.bluetooth),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ if (btDevices.isNotEmpty()) {
+ btDevices.forEach { device ->
+ DeviceListItem(
+ device,
+ device.fullAddress == selectedDevice
+ ) {
+ scanModel.onSelected(device)
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalAlignment = CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Default.BluetoothDisabled,
+ contentDescription = stringResource(R.string.no_ble_devices),
+ modifier = Modifier.size(96.dp)
+ )
+ Text(
+ text = stringResource(R.string.no_ble_devices),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+ }
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ val bluetoothPermissions = context.getBluetoothPermissions()
+ if (bluetoothPermissions.isEmpty()) {
+ // If no permissions needed, trigger the scan directly (or via ViewModel)
+ scanModel.startScan()
+ } else {
+ if (bluetoothPermissions.any { permission ->
+ ActivityCompat.shouldShowRequestPermissionRationale(
+ context as Activity,
+ permission
+ )
+ }
+ ) {
+ showBluetoothRationaleDialog()
+ } else {
+ requestBluetoothPermission(bluetoothPermissions)
+ }
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Bluetooth,
+ contentDescription = stringResource(R.string.scan)
+ )
+ Text(stringResource(R.string.scan))
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
new file mode 100644
index 000000000..a72b344d1
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2025 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 .
+ */
+
+package com.geeksville.mesh.ui.connections.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.CloudQueue
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.outlined.CloudDone
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import com.geeksville.mesh.R
+import com.geeksville.mesh.model.BTScanModel
+
+@Composable
+fun DeviceListItem(
+ device: BTScanModel.DeviceListEntry,
+ selected: Boolean,
+ onSelect: () -> Unit,
+) {
+ val icon = if (device.isBLE) {
+ Icons.Default.Bluetooth
+ } else if (device.isUSB) {
+ Icons.Default.Usb
+ } else if (device.isTCP) {
+ Icons.Default.Wifi
+ } else if (device.isDisconnect) {
+ Icons.Default.Cancel
+ } else {
+ Icons.Default.Add
+ }
+
+ val contentDescription = if (device.isBLE) {
+ stringResource(R.string.bluetooth)
+ } else if (device.isUSB) {
+ stringResource(R.string.serial)
+ } else if (device.isTCP) {
+ stringResource(R.string.network)
+ } else if (device.isDisconnect) {
+ stringResource(R.string.disconnect)
+ } else {
+ stringResource(R.string.add)
+ }
+
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = selected,
+ onClick = onSelect,
+ ),
+ headlineContent = { Text(device.name) },
+ leadingContent = {
+ Icon(
+ icon,
+ contentDescription
+ )
+ },
+ trailingContent = {
+ if (selected) {
+ Icon(
+ Icons.Outlined.CloudDone,
+ stringResource(R.string.connected),
+ tint = Color(color = 0xFF30C047)
+ )
+ } else {
+ Icon(
+ Icons.Default.CloudQueue,
+ stringResource(R.string.not_connected)
+ )
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
new file mode 100644
index 000000000..e949c9494
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2025 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 .
+ */
+
+package com.geeksville.mesh.ui.connections.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.WifiFind
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.R
+import com.geeksville.mesh.model.BTScanModel
+import com.geeksville.mesh.repository.network.NetworkRepository
+import com.geeksville.mesh.ui.connections.isIPAddress
+
+@Suppress("MagicNumber", "LongMethod")
+@Composable
+fun NetworkDevices(
+ networkDevices: List,
+ selectedDevice: String,
+ scanModel: BTScanModel,
+) {
+ val manualIpAddress = rememberTextFieldState("")
+ val manualIpPort = rememberTextFieldState(NetworkRepository.Companion.SERVICE_PORT.toString())
+ if (networkDevices.isNotEmpty()) {
+ Text(
+ text = stringResource(R.string.network),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ networkDevices.forEach { device ->
+ DeviceListItem(device, device.fullAddress == selectedDevice) {
+ scanModel.onSelected(device)
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalAlignment = CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Default.WifiFind,
+ contentDescription = stringResource(R.string.no_network_devices),
+ modifier = Modifier.size(96.dp)
+ )
+ Text(
+ text = stringResource(R.string.no_network_devices),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = ("t$manualIpAddress:$manualIpPort" == selectedDevice),
+ onClick = {
+ if (manualIpAddress.text.toString().isIPAddress()) {
+ scanModel.onSelected(
+ BTScanModel.DeviceListEntry(
+ "",
+ "t$manualIpAddress:$manualIpPort",
+ true
+ )
+ )
+ }
+ },
+ enabled = manualIpAddress.text.toString().isIPAddress(),
+ role = Role.Companion.RadioButton
+ )
+ .padding(8.dp),
+ verticalAlignment = Alignment.Companion.CenterVertically
+ ) {
+ RadioButton(
+ selected = ("t$manualIpAddress:$manualIpPort" == selectedDevice),
+ onClick = null,
+ enabled = manualIpAddress.toString().isIPAddress()
+ )
+ OutlinedTextField(
+ state = manualIpAddress,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ label = { Text(stringResource(R.string.ip_address)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Companion.Number,
+ imeAction = ImeAction.Done
+ ),
+ modifier = Modifier
+ .weight(0.7f)
+ .padding(start = 16.dp)
+ )
+ OutlinedTextField(
+ state = manualIpPort,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ label = { Text(stringResource(R.string.ip_port)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Companion.Number,
+ imeAction = ImeAction.Done
+ ),
+ modifier = Modifier
+ .weight(weight = 0.3f)
+ .padding(start = 8.dp)
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
new file mode 100644
index 000000000..1ae93a286
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025 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 .
+ */
+
+package com.geeksville.mesh.ui.connections.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.UsbOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.geeksville.mesh.R
+import com.geeksville.mesh.model.BTScanModel
+
+@Composable
+fun UsbDevices(
+ usbDevices: List,
+ selectedDevice: String,
+ scanModel: BTScanModel
+) {
+ Text(
+ text = stringResource(R.string.serial),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ if (usbDevices.isNotEmpty()) {
+ usbDevices.forEach { device ->
+ DeviceListItem(device, device.fullAddress == selectedDevice) {
+ scanModel.onSelected(device)
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalAlignment = CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Default.UsbOff,
+ contentDescription = stringResource(R.string.no_usb_devices),
+ modifier = Modifier.size(96.dp)
+ )
+ Text(
+ text = stringResource(R.string.no_usb_devices),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7dc143d44..a96346b9f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -697,4 +697,8 @@
Remote
(%1$d online / %2$d total)
React
+ Disconnect
+ No Bluetooth devices found.
+ No Network devices found.
+ No USB Serial devices found.