diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index 392e4d2b8..f1044070b 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -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(); diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 9f28a426c..9c6df57cf 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt index bcb1f7f11..fcc17edd9 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/ChannelSetRepository.kt @@ -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() + } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index faaead244..29e35e7ae 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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 { diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt index cf8dc2540..536aaacfc 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -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() +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt index bbefcab02..1659009db 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt @@ -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() } 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) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/ChannelSettingsItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/ChannelSettingsItemList.kt new file mode 100644 index 000000000..5f88a33fb --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/ChannelSettingsItemList.kt @@ -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, + maxChannels: Int = 8, + enabled: Boolean, + focusManager: FocusManager, + onSaveClicked: (List) -> Unit, +) { + ChannelSettingsItemList( + settingsList = settingsList, + maxChannels = maxChannels, + enabled = enabled, + focusManager = focusManager, + onPositiveClicked = onSaveClicked, + onNegativeClicked = { } + ) +} + +@Composable +fun ChannelSettingsItemList( + settingsList: List, + maxChannels: Int = 8, + enabled: Boolean, + focusManager: FocusManager, + onNegativeClicked: () -> Unit, + @StringRes positiveText: Int = R.string.send, + onPositiveClicked: (List) -> Unit, + ) { + val settingsListInput = remember { + mutableStateListOf().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 = { }, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt new file mode 100644 index 000000000..4f785d48d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/EditChannelDialog.kt @@ -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 = { }, + ) +}