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.