mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-04 14:13:47 -04:00
feat: add channel editor (#627)
This commit is contained in:
@@ -110,6 +110,10 @@ interface IMeshService {
|
||||
/// It sets a Channel protobuf via admin packet
|
||||
void setChannel(in byte []payload);
|
||||
|
||||
/// Set and get a Channel protobuf via admin packet
|
||||
void setRemoteChannel(in int destNum, in byte []payload);
|
||||
void getRemoteChannel(in int requestId, in int destNum, in int channelIndex);
|
||||
|
||||
/// Send beginEditSettings admin packet to nodeNum
|
||||
void beginEditSettings();
|
||||
|
||||
|
||||
@@ -250,6 +250,12 @@ class UIViewModel @Inject constructor(
|
||||
"Request getOwner error"
|
||||
)
|
||||
|
||||
fun getChannel(destNum: Int, index: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
|
||||
"Request getChannel error"
|
||||
)
|
||||
|
||||
fun getConfig(destNum: Int, configType: Int) = request(
|
||||
destNum,
|
||||
{ service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
|
||||
@@ -452,6 +458,10 @@ class UIViewModel @Inject constructor(
|
||||
this._channelSet = channelSet.protobuf
|
||||
}
|
||||
|
||||
fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) {
|
||||
meshService?.setRemoteChannel(destNum, channel.toByteArray())
|
||||
}
|
||||
|
||||
/// our name in hte radio
|
||||
/// Note, we generate owner initials automatically for now
|
||||
/// our activity will read this from prefs or set it to the empty string
|
||||
|
||||
@@ -42,7 +42,11 @@ class ChannelSetRepository @Inject constructor(
|
||||
|
||||
suspend fun addSettings(channel: ChannelProtos.Channel) {
|
||||
channelSetStore.updateData { preference ->
|
||||
preference.toBuilder().addSettings(channel.settings).build()
|
||||
if (preference.settingsCount > channel.index) {
|
||||
preference.toBuilder().setSettings(channel.index, channel.settings).build()
|
||||
} else {
|
||||
preference.toBuilder().addSettings(channel.settings).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1407,12 +1407,6 @@ class MeshService : Service(), Logging {
|
||||
}.forEach(::requestConfig)
|
||||
}
|
||||
|
||||
private fun requestChannel(channelIndex: Int) {
|
||||
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) {
|
||||
getChannelRequest = channelIndex + 1
|
||||
})
|
||||
}
|
||||
|
||||
private fun setChannel(ch: ChannelProtos.Channel) {
|
||||
if (ch.index == 0 || ch.settings.name.lowercase() == "admin") adminChannelIndex = ch.index
|
||||
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket(wantResponse = true) {
|
||||
@@ -1731,8 +1725,23 @@ class MeshService : Service(), Logging {
|
||||
}
|
||||
|
||||
override fun setChannel(payload: ByteArray?) = toRemoteExceptions {
|
||||
val parsed = ChannelProtos.Channel.parseFrom(payload)
|
||||
setChannel(parsed)
|
||||
with(ChannelProtos.Channel.parseFrom(payload)) {
|
||||
if (index == 0 || settings.name.lowercase() == "admin") adminChannelIndex = index
|
||||
}
|
||||
setRemoteChannel(myNodeNum, payload)
|
||||
}
|
||||
|
||||
override fun setRemoteChannel(destNum: Int, payload: ByteArray?) = toRemoteExceptions {
|
||||
val channel = ChannelProtos.Channel.parseFrom(payload)
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) {
|
||||
setChannel = channel
|
||||
})
|
||||
}
|
||||
|
||||
override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions {
|
||||
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) {
|
||||
getChannelRequest = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
override fun beginEditSettings() = toRemoteExceptions {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Check
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.Edit
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -49,10 +50,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.analytics.DataPair
|
||||
import com.geeksville.mesh.android.GeeksvilleApplication
|
||||
import com.geeksville.mesh.android.Logging
|
||||
@@ -64,6 +67,7 @@ import com.geeksville.mesh.android.BuildUtils.errormsg
|
||||
import com.geeksville.mesh.android.getCameraPermissions
|
||||
import com.geeksville.mesh.android.hasCameraPermission
|
||||
import com.geeksville.mesh.channelSet
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.copy
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.ChannelOption
|
||||
@@ -71,16 +75,16 @@ import com.geeksville.mesh.model.ChannelSet
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.components.DropDownPreference
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
import com.geeksville.mesh.ui.components.RegularPreference
|
||||
import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList
|
||||
import com.geeksville.mesh.ui.components.config.EditChannelDialog
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.protobuf.ByteString
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.SecureRandom
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChannelFragment : ScreenFragment("Channel"), Logging {
|
||||
@@ -121,7 +125,7 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
val primaryChannel = ChannelSet(channelSet).primaryChannel
|
||||
val channelUrl = ChannelSet(channelSet).getChannelUrl()
|
||||
|
||||
var isEditing by remember(channelSet) { mutableStateOf(channelSet != channels.protobuf) }
|
||||
val isEditing by remember(channelSet) { mutableStateOf(channelSet != channels.protobuf) }
|
||||
|
||||
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
if (result.contents != null) {
|
||||
@@ -159,14 +163,9 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
|
||||
/// Send new channel settings to the device
|
||||
fun installSettings(
|
||||
newChannel: ChannelProtos.ChannelSettings,
|
||||
newLoRaConfig: ConfigProtos.Config.LoRaConfig
|
||||
newChannelSet: AppOnlyProtos.ChannelSet
|
||||
) {
|
||||
val newSet = ChannelSet(
|
||||
channelSet {
|
||||
settings.add(newChannel)
|
||||
loraConfig = newLoRaConfig
|
||||
})
|
||||
val newSet = ChannelSet(newChannelSet)
|
||||
// Try to change the radio, if it fails, tell the user why and throw away their edits
|
||||
try {
|
||||
viewModel.setChannels(newSet)
|
||||
@@ -183,6 +182,17 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
fun installSettings(
|
||||
newChannel: ChannelProtos.ChannelSettings,
|
||||
newLoRaConfig: ConfigProtos.Config.LoRaConfig
|
||||
) {
|
||||
val newSet = channelSet {
|
||||
settings.add(newChannel)
|
||||
loraConfig = newLoRaConfig
|
||||
}
|
||||
installSettings(newSet)
|
||||
}
|
||||
|
||||
fun resetButton() {
|
||||
// User just locked it, we should warn and then apply changes to radio
|
||||
MaterialAlertDialogBuilder(context)
|
||||
@@ -205,48 +215,12 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
}
|
||||
|
||||
fun sendButton() {
|
||||
channels.primaryChannel?.let { oldPrimary ->
|
||||
var newSettings = oldPrimary.settings
|
||||
val newName = channelSet.getSettings(0).name.trim()
|
||||
|
||||
// Find the new modem config
|
||||
var newModemPreset = channelSet.loraConfig.modemPreset
|
||||
if (newModemPreset == ConfigProtos.Config.LoRaConfig.ModemPreset.UNRECOGNIZED) // Huh? didn't find it - keep same
|
||||
newModemPreset = oldPrimary.loraConfig.modemPreset
|
||||
|
||||
// Generate a new AES256 key if the channel name is non-default (empty)
|
||||
val shouldUseRandomKey = newName.isNotEmpty()
|
||||
if (shouldUseRandomKey) {
|
||||
|
||||
// Install a new customized channel
|
||||
debug("ASSIGNING NEW AES256 KEY")
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
newSettings = newSettings.copy {
|
||||
name = newName
|
||||
psk = ByteString.copyFrom(bytes)
|
||||
}
|
||||
} else {
|
||||
debug("Switching back to default channel")
|
||||
newSettings = Channel.default.settings
|
||||
}
|
||||
|
||||
// No matter what apply the speed selection from the user
|
||||
val newLoRaConfig = viewModel.config.lora.copy {
|
||||
usePreset = true
|
||||
modemPreset = newModemPreset
|
||||
bandwidth = 0
|
||||
spreadFactor = 0
|
||||
codingRate = 0
|
||||
}
|
||||
|
||||
val humanName = Channel(newSettings, newLoRaConfig).humanName
|
||||
|
||||
primaryChannel?.let { primaryChannel ->
|
||||
val humanName = primaryChannel.humanName
|
||||
val message = buildString {
|
||||
append(context.getString(R.string.are_you_sure_channel))
|
||||
if (!shouldUseRandomKey)
|
||||
append("\n\n" + context.getString(R.string.warning_default_psk).format(humanName))
|
||||
if (primaryChannel.settings == Channel.default.settings)
|
||||
append("\n\n" + context.getString(R.string.warning_default_psk, humanName))
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
@@ -255,36 +229,61 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
.setNeutralButton(R.string.cancel) { _, _ ->
|
||||
channelSet = channels.protobuf
|
||||
}
|
||||
.setPositiveButton(context.getString(R.string.accept)) { _, _ ->
|
||||
// Generate a new channel with only the changes the user can change in the GUI
|
||||
installSettings(newSettings, newLoRaConfig)
|
||||
.setPositiveButton(R.string.accept) { _, _ ->
|
||||
installSettings(channelSet)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(channelSet) {
|
||||
if (settingsCount > index) getSettings(index) else channelSettings { }
|
||||
},
|
||||
onAddClick = {
|
||||
with(channelSet) {
|
||||
if (settingsCount > index) channelSet = copy { settings[index] = it }
|
||||
else channelSet = copy { settings.add(it) }
|
||||
}
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
var showChannelEditor by remember { mutableStateOf(false) }
|
||||
if (showChannelEditor) ChannelSettingsItemList(
|
||||
settingsList = channelSet.settingsList,
|
||||
enabled = connected,
|
||||
focusManager = focusManager,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
showChannelEditor = false
|
||||
},
|
||||
positiveText = R.string.save,
|
||||
onPositiveClicked = {
|
||||
focusManager.clearFocus()
|
||||
showChannelEditor = false
|
||||
channelSet = channelSet.toBuilder().clearSettings().addAllSettings(it).build()
|
||||
}
|
||||
)
|
||||
|
||||
if (!showChannelEditor) LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
item {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
EditTextPreference(
|
||||
RegularPreference(
|
||||
title = stringResource(R.string.channel_name),
|
||||
value = if (isFocused) channelSet.getSettings(0).name else primaryChannel?.humanName.orEmpty(),
|
||||
maxSize = 11, // name max_size:12
|
||||
subtitle = primaryChannel?.humanName.orEmpty(),
|
||||
onClick = { showChannelEditor = true },
|
||||
enabled = connected,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = {
|
||||
val newSettings = channelSet.getSettings(0).copy { name = it }
|
||||
channelSet = channelSet.copy { settings[0] = newSettings }
|
||||
},
|
||||
onFocusChanged = { isFocused = it.isFocused }
|
||||
trailingIcon = Icons.TwoTone.Edit
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,8 +315,7 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
// channelSet failed to update, isError true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = connected,
|
||||
label = { Text("URL") },
|
||||
isError = isError,
|
||||
@@ -380,7 +378,6 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
onCancelClicked = {
|
||||
focusManager.clearFocus()
|
||||
channelSet = channels.protobuf
|
||||
isEditing = false
|
||||
},
|
||||
onSaveClicked = {
|
||||
focusManager.clearFocus()
|
||||
@@ -408,8 +405,8 @@ fun ChannelScreen(viewModel: UIViewModel = viewModel()) {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
}
|
||||
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//private fun ChannelScreenPreview() {
|
||||
// ChannelScreen()
|
||||
//}
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelScreenPreview() {
|
||||
// ChannelScreen()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -45,6 +46,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import com.geeksville.mesh.AdminProtos
|
||||
import com.geeksville.mesh.AdminProtos.AdminMessage.ConfigType
|
||||
import com.geeksville.mesh.AdminProtos.AdminMessage.ModuleConfigType
|
||||
import com.geeksville.mesh.ChannelProtos
|
||||
import com.geeksville.mesh.ConfigProtos.Config
|
||||
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
@@ -52,6 +54,8 @@ import com.geeksville.mesh.NodeInfo
|
||||
import com.geeksville.mesh.Portnums
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.channel
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.config
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.moduleConfig
|
||||
@@ -61,6 +65,7 @@ import com.geeksville.mesh.ui.components.TextDividerPreference
|
||||
import com.geeksville.mesh.ui.components.config.AudioConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.BluetoothConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.CannedMessageConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.ChannelSettingsItemList
|
||||
import com.geeksville.mesh.ui.components.config.DeviceConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.DisplayConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.ExternalNotificationConfigItemList
|
||||
@@ -101,7 +106,6 @@ class DeviceSettingsFragment(val node: NodeInfo) : ScreenFragment("Radio Configu
|
||||
}
|
||||
|
||||
enum class ConfigDest(val title: String, val route: String, val config: ConfigType) {
|
||||
USER("User", "user", ConfigType.UNRECOGNIZED),
|
||||
DEVICE("Device", "device", ConfigType.DEVICE_CONFIG),
|
||||
POSITION("Position", "position", ConfigType.POSITION_CONFIG),
|
||||
POWER("Power", "power", ConfigType.POWER_CONFIG),
|
||||
@@ -132,8 +136,10 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
|
||||
val connected = connectionState == MeshService.ConnectionState.CONNECTED
|
||||
|
||||
val destNum = node.num
|
||||
val maxChannels = viewModel.myNodeInfo.value?.maxChannels ?: 8
|
||||
|
||||
var userConfig by remember { mutableStateOf(MeshProtos.User.getDefaultInstance()) }
|
||||
val channelList = remember { mutableStateListOf<ChannelProtos.ChannelSettings>() }
|
||||
var radioConfig by remember { mutableStateOf(Config.getDefaultInstance()) }
|
||||
var moduleConfig by remember { mutableStateOf(ModuleConfig.getDefaultInstance()) }
|
||||
|
||||
@@ -150,7 +156,23 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
|
||||
val parsed = AdminProtos.AdminMessage.parseFrom(data.payload)
|
||||
when (parsed.payloadVariantCase) {
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
|
||||
val response = parsed.getChannelResponse // TODO
|
||||
val response = parsed.getChannelResponse
|
||||
if (response.index + 1 < maxChannels) {
|
||||
// Stop once we get to the first disabled entry
|
||||
if (response.role != ChannelProtos.Channel.Role.DISABLED) {
|
||||
// Not done yet, request next channel
|
||||
channelList.add(response.index, response.settings)
|
||||
viewModel.getChannel(destNum, response.index + 1)
|
||||
} else {
|
||||
// Received the last channel, start channel editor
|
||||
isWaiting = false
|
||||
navController.navigate("channels")
|
||||
}
|
||||
} else {
|
||||
// Received max channels, start channel editor
|
||||
isWaiting = false
|
||||
navController.navigate("channels")
|
||||
}
|
||||
}
|
||||
|
||||
AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> {
|
||||
@@ -199,8 +221,10 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
|
||||
isWaiting = true
|
||||
// clearAllConfigs() ?
|
||||
when (configType) {
|
||||
ConfigType.UNRECOGNIZED -> {
|
||||
viewModel.getOwner(destNum)
|
||||
"USER" -> { viewModel.getOwner(destNum) }
|
||||
"CHANNELS" -> {
|
||||
channelList.clear()
|
||||
viewModel.getChannel(destNum, 0)
|
||||
}
|
||||
is ConfigType -> {
|
||||
viewModel.getConfig(destNum, configType.number)
|
||||
@@ -218,6 +242,33 @@ fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) {
|
||||
},
|
||||
)
|
||||
}
|
||||
composable("channels") {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = channelList,
|
||||
enabled = connected,
|
||||
maxChannels = maxChannels,
|
||||
focusManager = focusManager,
|
||||
onSaveClicked = { channelListInput ->
|
||||
focusManager.clearFocus()
|
||||
(0 until channelList.size.coerceAtLeast(channelListInput.size)).map { i ->
|
||||
channel {
|
||||
role = when (i) {
|
||||
0 -> ChannelProtos.Channel.Role.PRIMARY
|
||||
in 1 until channelListInput.size -> ChannelProtos.Channel.Role.SECONDARY
|
||||
else -> ChannelProtos.Channel.Role.DISABLED
|
||||
}
|
||||
index = i
|
||||
settings = channelListInput.getOrNull(i) ?: channelSettings { }
|
||||
}
|
||||
}.forEach { newChannel ->
|
||||
if (newChannel.settings != channelList.getOrNull(newChannel.index))
|
||||
viewModel.setRemoteChannel(destNum, newChannel)
|
||||
}
|
||||
channelList.clear()
|
||||
channelList.addAll(channelListInput)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("user") {
|
||||
UserConfigItemList(
|
||||
userConfig = userConfig,
|
||||
@@ -503,25 +554,16 @@ fun RadioSettingsScreen(
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
stickyHeader {
|
||||
TextDividerPreference(headerText)
|
||||
}
|
||||
stickyHeader { TextDividerPreference(headerText) }
|
||||
|
||||
item {
|
||||
PreferenceCategory(
|
||||
stringResource(id = R.string.device_settings)
|
||||
)
|
||||
}
|
||||
item { PreferenceCategory(stringResource(R.string.device_settings)) }
|
||||
item { NavCard("User", enabled = enabled) { onRouteClick("USER") } }
|
||||
item { NavCard("Channels", enabled = enabled) { onRouteClick("CHANNELS") } }
|
||||
items(ConfigDest.values()) { configs ->
|
||||
NavCard(configs.title, enabled = enabled) { onRouteClick(configs.config) }
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceCategory(
|
||||
stringResource(id = R.string.module_settings)
|
||||
)
|
||||
}
|
||||
|
||||
item { PreferenceCategory(stringResource(R.string.module_settings)) }
|
||||
items(ModuleDest.values()) { modules ->
|
||||
NavCard(modules.title, enabled = enabled) { onRouteClick(modules.config) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Chip
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Add
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.focus.FocusManager
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ChannelProtos.ChannelSettings
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.PreferenceFooter
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
index: Int,
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.clickable(enabled = enabled) { onEditClick() },
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp)
|
||||
) {
|
||||
Chip(onClick = onEditClick) { Text("$index") }
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { onDeleteClick() }) {
|
||||
Icon(
|
||||
Icons.TwoTone.Close,
|
||||
stringResource(R.string.delete),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelSettingsItemList(
|
||||
settingsList: List<ChannelSettings>,
|
||||
maxChannels: Int = 8,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onSaveClicked: (List<ChannelSettings>) -> Unit,
|
||||
) {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = settingsList,
|
||||
maxChannels = maxChannels,
|
||||
enabled = enabled,
|
||||
focusManager = focusManager,
|
||||
onPositiveClicked = onSaveClicked,
|
||||
onNegativeClicked = { }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelSettingsItemList(
|
||||
settingsList: List<ChannelSettings>,
|
||||
maxChannels: Int = 8,
|
||||
enabled: Boolean,
|
||||
focusManager: FocusManager,
|
||||
onNegativeClicked: () -> Unit,
|
||||
@StringRes positiveText: Int = R.string.send,
|
||||
onPositiveClicked: (List<ChannelSettings>) -> Unit,
|
||||
) {
|
||||
val settingsListInput = remember {
|
||||
mutableStateListOf<ChannelSettings>().apply { addAll(settingsList) }
|
||||
}
|
||||
var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
|
||||
|
||||
if (showEditChannelDialog != null) {
|
||||
val index = showEditChannelDialog ?: return
|
||||
EditChannelDialog(
|
||||
channelSettings = with(settingsListInput) {
|
||||
if (size > index) get(index) else channelSettings { }
|
||||
},
|
||||
onAddClick = {
|
||||
if (settingsListInput.size > index) settingsListInput[index] = it
|
||||
else settingsListInput.add(it)
|
||||
showEditChannelDialog = null
|
||||
},
|
||||
onDismissRequest = { showEditChannelDialog = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (maxChannels > settingsListInput.size) FloatingActionButton(
|
||||
onClick = {
|
||||
settingsListInput.add(channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
})
|
||||
showEditChannelDialog = settingsListInput.size - 1
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
) { Icon(Icons.TwoTone.Add, stringResource(R.string.add)) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item { PreferenceCategory(text = "Channels") }
|
||||
|
||||
itemsIndexed(settingsListInput) { index, channel ->
|
||||
ChannelCard(
|
||||
index = index,
|
||||
title = channel.name,
|
||||
enabled = enabled,
|
||||
onEditClick = { showEditChannelDialog = index },
|
||||
onDeleteClick = { settingsListInput.removeAt(index) }
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceFooter(
|
||||
// FIXME workaround until we use navigation in ChannelFragment
|
||||
enabled = positiveText != R.string.send
|
||||
|| !settingsListInput.containsAll(settingsList)
|
||||
|| !settingsList.containsAll(settingsListInput),
|
||||
negativeText = R.string.cancel,
|
||||
onNegativeClicked = {
|
||||
focusManager.clearFocus()
|
||||
settingsListInput.clear()
|
||||
settingsListInput.addAll(settingsList)
|
||||
onNegativeClicked()
|
||||
},
|
||||
positiveText = positiveText,
|
||||
onPositiveClicked = { onPositiveClicked(settingsListInput) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelSettingsPreview() {
|
||||
ChannelSettingsItemList(
|
||||
settingsList = listOf(
|
||||
channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
name = Channel.default.name
|
||||
},
|
||||
channelSettings {
|
||||
name = stringResource(R.string.channel_name)
|
||||
},
|
||||
),
|
||||
enabled = true,
|
||||
focusManager = LocalFocusManager.current,
|
||||
onSaveClicked = { },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.geeksville.mesh.ui.components.config
|
||||
|
||||
import android.util.Base64
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Close
|
||||
import androidx.compose.material.icons.twotone.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ChannelProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.channelSettings
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.components.EditTextPreference
|
||||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import com.google.protobuf.ByteString
|
||||
import com.google.protobuf.kotlin.toByteString
|
||||
import java.security.SecureRandom
|
||||
|
||||
@Composable
|
||||
fun EditChannelDialog(
|
||||
channelSettings: ChannelProtos.ChannelSettings,
|
||||
onAddClick: (ChannelProtos.ChannelSettings) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base64Flags = Base64.URL_SAFE + Base64.NO_WRAP
|
||||
fun encodeToString(input: ByteString) =
|
||||
Base64.encodeToString(input.toByteArray() ?: ByteArray(0), base64Flags)
|
||||
|
||||
var pskInput by remember { mutableStateOf(channelSettings.psk) }
|
||||
var pskString by remember(pskInput) { mutableStateOf(encodeToString(pskInput)) }
|
||||
val pskError = pskString != encodeToString(pskInput)
|
||||
|
||||
var nameInput by remember { mutableStateOf(channelSettings.name) }
|
||||
var uplinkInput by remember { mutableStateOf(channelSettings.uplinkEnabled) }
|
||||
var downlinkInput by remember { mutableStateOf(channelSettings.downlinkEnabled) }
|
||||
|
||||
fun getRandomKey() {
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(32)
|
||||
random.nextBytes(bytes)
|
||||
pskInput = ByteString.copyFrom(bytes)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
text = {
|
||||
AppCompatTheme {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
EditTextPreference(
|
||||
title = stringResource(R.string.channel_name),
|
||||
value = nameInput,
|
||||
maxSize = 11, // name max_size:12
|
||||
enabled = true,
|
||||
isError = false,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
onValueChanged = { nameInput = it },
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = pskString,
|
||||
onValueChange = {
|
||||
try {
|
||||
pskString = it
|
||||
val decoded = Base64.decode(it, base64Flags).toByteString()
|
||||
if (decoded.size() == 32) pskInput = decoded // 256 bit only
|
||||
} catch (ex: Throwable) {
|
||||
// Base64 decode failed, pskError true
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = true,
|
||||
label = { Text("PSK") },
|
||||
isError = pskError,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (pskError) {
|
||||
pskInput = channelSettings.psk
|
||||
pskString = encodeToString(pskInput)
|
||||
} else getRandomKey()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
if (pskError) Icons.TwoTone.Close else Icons.TwoTone.Refresh,
|
||||
contentDescription = stringResource(R.string.reset),
|
||||
tint = if (pskError) MaterialTheme.colors.error
|
||||
else LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Uplink enabled", // TODO move to resource strings
|
||||
modifier = modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = uplinkInput,
|
||||
onCheckedChange = { uplinkInput = it },
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Downlink enabled", // TODO move to resource strings
|
||||
modifier = modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = downlinkInput,
|
||||
onCheckedChange = { downlinkInput = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = onDismissRequest
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
Button(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 24.dp)
|
||||
.weight(1f),
|
||||
onClick = {
|
||||
onAddClick(channelSettings {
|
||||
psk = pskInput
|
||||
name = nameInput.trim()
|
||||
uplinkEnabled = uplinkInput
|
||||
downlinkEnabled = downlinkInput
|
||||
})
|
||||
},
|
||||
enabled = !pskError,
|
||||
) { Text(stringResource(R.string.save)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun EditChannelDialogPreview() {
|
||||
EditChannelDialog(
|
||||
channelSettings = channelSettings {
|
||||
psk = Channel.default.settings.psk
|
||||
name = Channel.default.name
|
||||
},
|
||||
onAddClick = { },
|
||||
onDismissRequest = { },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user