Meshtastic-Android/app/src/main/java/com/geeksville/mesh/ui/Settings.kt
James Rich 8cde47bdf9
refactor: migrate to Compose navigation (#1835)
Co-authored-by: andrekir <andrekir@pm.me>
2025-05-15 08:05:30 -05:00

617 lines
25 KiB
Kotlin

/*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.net.InetAddresses
import android.os.Build
import android.util.Patterns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Checkbox
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.RadioButton
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.info
import com.geeksville.mesh.android.BuildUtils.reportError
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.getBluetoothPermissions
import com.geeksville.mesh.android.getLocationPermissions
import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.android.hasLocationPermission
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.UIViewModel
import com.geeksville.mesh.service.MeshService
import kotlinx.coroutines.delay
fun String?.isIPAddress(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
this != null && Patterns.IP_ADDRESS.matcher(this).matches()
} else {
InetAddresses.isNumericAddress(this.toString())
}
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun SettingsScreen(
uiViewModel: UIViewModel = hiltViewModel(),
scanModel: BTScanModel = hiltViewModel(),
bluetoothViewModel: BluetoothViewModel = hiltViewModel(),
onSetRegion: () -> Unit,
) {
val currentRegion = uiViewModel.region
val regionUnset = currentRegion == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val connectionState by uiViewModel.connectionState.collectAsState(MeshService.ConnectionState.DISCONNECTED)
val devices by scanModel.devices.observeAsState(emptyMap())
val scanning by scanModel.spinner.observeAsState(false)
val receivingLocationUpdates by uiViewModel.receivingLocationUpdates.collectAsState(false)
val context = LocalContext.current
val app = (context.applicationContext as GeeksvilleApplication)
val isGooglePlayAvailable = context.isGooglePlayAvailable()
val info by uiViewModel.myNodeInfo.collectAsState()
val isAnalyticsAllowed = app.isAnalyticsAllowed
val selectedDevice = scanModel.selectedNotNull
val bluetoothEnabled by bluetoothViewModel.enabled.observeAsState()
val isGpsDisabled = context.gpsDisabled()
LaunchedEffect(isGpsDisabled) {
if (isGpsDisabled) {
uiViewModel.showSnackbar(context.getString(R.string.location_disabled))
}
}
LaunchedEffect(bluetoothEnabled) {
if (bluetoothEnabled == false) {
uiViewModel.showSnackbar(context.getString(R.string.bluetooth_disabled))
}
}
// when scanning is true - wait 10000ms and then stop scanning
LaunchedEffect(scanning) {
if (scanning) {
delay(SCAN_PERIOD)
scanModel.stopScan()
}
}
// State for manual IP address input
var manualIpAddress by remember { mutableStateOf("") }
// State for the device scan dialog
var showScanDialog by remember { mutableStateOf(false) }
val scanResults by scanModel.scanResult.observeAsState(emptyMap())
// State for the location permission rationale dialog
var showLocationRationaleDialog by remember { mutableStateOf(false) }
// State for the Bluetooth permission rationale dialog
var showBluetoothRationaleDialog by remember { mutableStateOf(false) }
// State for the Report Bug dialog
var showReportBugDialog by remember { mutableStateOf(false) }
// Remember the permission launchers
val requestLocationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissions ->
if (permissions.entries.all { it.value }) {
uiViewModel.provideLocation.value = true
uiViewModel.meshService?.startProvideLocation()
} else {
debug("User denied location permission")
uiViewModel.showSnackbar(context.getString(R.string.why_background_required))
}
bluetoothViewModel.permissionsUpdated()
}
)
val requestBluetoothPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { permissions ->
if (permissions.entries.all { it.value }) {
info("Bluetooth permissions granted")
// We need to call the scan function which is in the Fragment
// Since we can't directly call scanLeDevice() from Composable,
// we might need to rethink how scanning is triggered or
// pass the scan trigger as a lambda.
// For now, let's assume we trigger the scan outside the Composable
// after permissions are granted. We can add a callback to the ViewModel.
scanModel.startScan()
} else {
warn("Bluetooth permissions denied")
uiViewModel.showSnackbar(context.permissionMissing)
}
bluetoothViewModel.permissionsUpdated()
}
)
// Observe scan results to show the dialog
if (scanResults.isNotEmpty()) {
showScanDialog = true
}
LaunchedEffect(connectionState, regionUnset) {
when (connectionState) {
MeshService.ConnectionState.CONNECTED ->
// Include region unset warning in status string if applicable
if (regionUnset) R.string.must_set_region else R.string.connected_to
MeshService.ConnectionState.DISCONNECTED -> R.string.not_connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping
else -> null
}.let {
val firmwareString =
info?.firmwareString ?: context.getString(R.string.unknown)
if (it != null) {
scanModel.setErrorText(context.getString(it, firmwareString))
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(scrollState)
) {
// Scan Status Text
Text(
text = scanStatusText.orEmpty(),
fontSize = 14.sp,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Set Region Button
val isConnected = connectionState == MeshService.ConnectionState.CONNECTED
if (isConnected && regionUnset) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
onSetRegion()
}
) {
Text(stringResource(R.string.set_region))
}
Spacer(modifier = Modifier.height(16.dp))
}
// Device List and Manual Input
Text(
text = stringResource(R.string.device),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(vertical = 8.dp)
)
// Progress bar while scanning
if (scanning) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
Column(modifier = Modifier.selectableGroup()) {
devices.values.forEach { device ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = (device.fullAddress == selectedDevice),
onClick = {
if (device.fullAddress == "n") {
uiViewModel.showSnackbar("Demo Mode enabled")
scanModel.showMockInterface()
}
if (!device.bonded) {
uiViewModel.showSnackbar(context.getString(R.string.starting_pairing))
}
scanModel.onSelected(device)
}
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (device.fullAddress == selectedDevice),
onClick = {
if (device.fullAddress == "n") {
uiViewModel.showSnackbar("Demo Mode enabled")
scanModel.showMockInterface()
}
if (!device.bonded) {
uiViewModel.showSnackbar(context.getString(R.string.starting_pairing))
}
scanModel.onSelected(device)
}
)
Text(
text = device.name,
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(start = 16.dp)
)
}
}
// Manual IP Address Input
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = ("t$manualIpAddress" == selectedDevice),
onClick = {
if (manualIpAddress.isIPAddress()) {
scanModel.onSelected(
BTScanModel.DeviceListEntry(
"",
"t$manualIpAddress",
true
)
)
} else {
// Optionally show a warning for invalid IP
}
}
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = ("t$manualIpAddress" == selectedDevice),
onClick = {
if (manualIpAddress.isIPAddress()) {
scanModel.onSelected(
BTScanModel.DeviceListEntry(
"",
"t$manualIpAddress",
true
)
)
} else {
// Optionally show a warning for invalid IP
}
}
)
OutlinedTextField(
value = manualIpAddress,
onValueChange = { manualIpAddress = it },
label = { Text(stringResource(R.string.ip_address)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Provide Location Checkbox
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = !isGpsDisabled
) {
val isChecked = !receivingLocationUpdates // Toggle the state
uiViewModel.provideLocation.value = isChecked
if (isChecked && !context.hasLocationPermission()) {
showLocationRationaleDialog = true // Show the Compose dialog
}
if (isChecked) {
uiViewModel.meshService?.startProvideLocation()
} else {
uiViewModel.meshService?.stopProvideLocation()
}
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = receivingLocationUpdates,
onCheckedChange = { isChecked ->
uiViewModel.provideLocation.value = isChecked
if (isChecked && !context.hasLocationPermission()) {
showLocationRationaleDialog = true
}
if (isChecked) {
uiViewModel.meshService?.startProvideLocation()
} else {
uiViewModel.meshService?.stopProvideLocation()
}
},
enabled = !isGpsDisabled // Disable if GPS is disabled
)
Text(
text = stringResource(R.string.provide_location_to_mesh),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(start = 16.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.colors.error,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
if (isAnalyticsAllowed) {
// Analytics Okay Checkbox
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = isGooglePlayAvailable,
) {
val app = (context.applicationContext as GeeksvilleApplication)
app.isAnalyticsAllowed = !app.isAnalyticsAllowed // Toggle the MutableState
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isAnalyticsAllowed,
onCheckedChange = { isChecked ->
debug("User changed analytics to $isChecked")
(context.applicationContext as GeeksvilleApplication).isAnalyticsAllowed =
isChecked
},
enabled = isGooglePlayAvailable
)
Text(
text = stringResource(R.string.analytics_okay),
style = MaterialTheme.typography.body1,
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
enabled = isAnalyticsAllowed,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Text(stringResource(R.string.report_bug))
}
}
// Floating Action Button (Change Radio)
Box(modifier = Modifier.fillMaxSize()) {
FloatingActionButton(
onClick = {
val bluetoothPermissions = context.getBluetoothPermissions()
if (bluetoothPermissions.isEmpty()) {
// If no permissions needed, trigger the scan directly (or via ViewModel)
scanModel.startScan()
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
context.findActivity()
.shouldShowRequestPermissionRationale(bluetoothPermissions.first())
) {
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.h6,
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))
}
}
)
}
}
private tailrec fun Context.findActivity(): Activity = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> error("No activity found")
}
private const val SCAN_PERIOD: Long = 10000 // 10 seconds