feat: add channel editor (#627)

This commit is contained in:
Andre K
2023-04-29 07:14:30 -03:00
committed by GitHub
parent c821eb3681
commit e5a860cb36
8 changed files with 561 additions and 104 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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) }
}

View File

@@ -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 = { },
)
}

View File

@@ -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 = { },
)
}