From 85e62eaab4b5a06f819bf1da01f4726fa50576c3 Mon Sep 17 00:00:00 2001 From: Andre K Date: Sat, 22 Apr 2023 12:06:25 -0300 Subject: [PATCH] feat: add remote node configuration (#626) --- .../com/geeksville/mesh/IMeshService.aidl | 31 +- .../java/com/geeksville/mesh/MainActivity.kt | 3 +- .../java/com/geeksville/mesh/model/UIState.kt | 209 +++++------- .../geeksville/mesh/service/MeshService.kt | 112 ++++-- .../mesh/ui/DeviceSettingsFragment.kt | 319 +++++++++++++----- .../com/geeksville/mesh/ui/UsersFragment.kt | 10 + .../config/CannedMessageConfigItemList.kt | 24 +- .../ExternalNotificationConfigItemList.kt | 37 +- .../config/PositionConfigItemList.kt | 10 +- .../components/config/UserConfigItemList.kt | 27 +- app/src/main/res/menu/menu_nodes.xml | 4 + app/src/main/res/values/strings.xml | 4 +- 12 files changed, 523 insertions(+), 267 deletions(-) diff --git a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl index 36114daf..392e4d2b 100644 --- a/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl +++ b/app/src/main/aidl/com/geeksville/mesh/IMeshService.aidl @@ -58,6 +58,9 @@ interface IMeshService { */ void setOwner(in MeshUser user); + void setRemoteOwner(in int destNum, in byte []payload); + void getRemoteOwner(in int requestId, in int destNum); + /// Return my unique user ID string String getMyId(); @@ -87,9 +90,21 @@ interface IMeshService { /// It sets a Config protobuf via admin packet void setConfig(in byte []payload); - /// This method is only intended for use in our GUI, so the user can set radio options - /// It sets a ModuleConfig protobuf via admin packet - void setModuleConfig(in byte []payload); + /// Set and get a Config protobuf via admin packet + void setRemoteConfig(in int destNum, in byte []payload); + void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue); + + /// Set and get a ModuleConfig protobuf via admin packet + void setModuleConfig(in int destNum, in byte []payload); + void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue); + + /// Set and get the Ext Notification Ringtone string via admin packet + void setRingtone(in int destNum, in String ringtone); + void getRingtone(in int requestId, in int destNum); + + /// Set and get the Canned Message Messages string via admin packet + void setCannedMessages(in int destNum, in String messages); + void getCannedMessages(in int requestId, in int destNum); /// This method is only intended for use in our GUI, so the user can set radio options /// It sets a Channel protobuf via admin packet @@ -102,22 +117,22 @@ interface IMeshService { void commitEditSettings(); /// Send position packet with wantResponse to nodeNum - void requestPosition(in int idNum, in Position position); + void requestPosition(in int destNum, in Position position); /// Send traceroute packet with wantResponse to nodeNum void requestTraceroute(in int requestId, in int destNum); /// Send Shutdown admin packet to nodeNum - void requestShutdown(in int idNum); + void requestShutdown(in int destNum); /// Send Reboot admin packet to nodeNum - void requestReboot(in int idNum); + void requestReboot(in int destNum); /// Send FactoryReset admin packet to nodeNum - void requestFactoryReset(in int idNum); + void requestFactoryReset(in int destNum); /// Send NodedbReset admin packet to nodeNum - void requestNodedbReset(in int idNum); + void requestNodedbReset(in int destNum); /// Returns a ChannelSet protobuf byte []getChannelSet(); diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 3c7b578e..c3b79214 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -769,8 +769,9 @@ class MainActivity : AppCompatActivity(), Logging { return true } R.id.radio_config -> { + val node = model.ourNodeInfo.value ?: return true supportFragmentManager.beginTransaction() - .add(R.id.mainActivityLayout, DeviceSettingsFragment()) + .add(R.id.mainActivityLayout, DeviceSettingsFragment(node)) .addToBackStack(null) .commit() return true 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 838ae3be..9f28a426 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -24,6 +24,7 @@ import com.geeksville.mesh.database.entity.MeshLog import com.geeksville.mesh.database.entity.QuickChatAction import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.MeshProtos.User import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.repository.datastore.ChannelSetRepository import com.geeksville.mesh.repository.datastore.LocalConfigRepository @@ -34,7 +35,6 @@ import com.geeksville.mesh.util.positionToMeter import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import org.osmdroid.bonuspack.kml.KmlDocument import org.osmdroid.views.MapView import org.osmdroid.views.overlay.FolderOverlay @@ -120,6 +119,10 @@ class UIViewModel @Inject constructor( private val _ourNodeInfo = MutableStateFlow(null) val ourNodeInfo: StateFlow = _ourNodeInfo + private val requestId = MutableStateFlow(null) + private val _packetResponse = MutableStateFlow(null) + val packetResponse: StateFlow = _packetResponse + init { viewModelScope.launch { meshLogRepository.getAllLogs().collect { logs -> @@ -148,6 +151,13 @@ class UIViewModel @Inject constructor( combine(nodeDB.nodes.asFlow(), nodeDB.myId.asFlow()) { nodes, id -> nodes[id] }.onEach { _ourNodeInfo.value = it }.launchIn(viewModelScope) + + combine(_meshLog, requestId) { packet, requestId -> + requestId?.run { packet.lastOrNull { it.meshPacket?.decoded?.requestId == requestId } } + }.onEach { + _packetResponse.value = it + }.launchIn(viewModelScope) + debug("ViewModel created") } @@ -174,42 +184,12 @@ class UIViewModel @Inject constructor( .filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 } }.asLiveData() - private val _packetResponse = MutableStateFlow(null) - val packetResponse: StateFlow = _packetResponse - /** * Called immediately after activity observes packetResponse */ fun clearPacketResponse() { - _packetResponse.tryEmit(null) - } - - /** - * Returns the packet response to a given [packetId] or null after [timeout] milliseconds - */ - private suspend fun getResponseBy(packetId: Int, timeout: Long) = withContext(Dispatchers.IO) { - withTimeoutOrNull(timeout) { - var packet: MeshLog? = null - while (packet == null) { - packet = _meshLog.value.lastOrNull { it.meshPacket?.decoded?.requestId == packetId } - if (packet == null) delay(1000) - } - packet - } - } - - fun requestTraceroute(destNum: Int) = viewModelScope.launch { - meshService?.let { service -> - try { - val packetId = service.packetId - val waitFactor = (service.nodes.count { it.isOnline } - 1) - .coerceAtMost(config.lora.hopLimit) - service.requestTraceroute(packetId, destNum) - _packetResponse.emit(getResponseBy(packetId, 20000L * waitFactor)) - } catch (ex: RemoteException) { - errormsg("Request traceroute error: ${ex.message}") - } - } + requestId.value = null + _packetResponse.value = null } fun generatePacketId(): Int? { @@ -247,6 +227,69 @@ class UIViewModel @Inject constructor( } } + private fun request( + destNum: Int, + requestAction: suspend (IMeshService, Int, Int) -> Unit, + errorMessage: String, + configType: Int = 0 + ) = viewModelScope.launch { + meshService?.let { service -> + val packetId = service.packetId + try { + requestAction(service, packetId, destNum) + requestId.value = packetId + } catch (ex: RemoteException) { + errormsg("$errorMessage: ${ex.message}") + } + } + } + + fun getOwner(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteOwner(packetId, dest) }, + "Request getOwner error" + ) + + fun getConfig(destNum: Int, configType: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) }, + "Request getConfig error", + configType + ) + + fun getModuleConfig(destNum: Int, configType: Int) = request( + destNum, + { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) }, + "Request getModuleConfig error", + configType + ) + + fun setRingtone(destNum: Int, ringtone: String) { + meshService?.setRingtone(destNum, ringtone) + } + + fun getRingtone(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getRingtone(packetId, dest) }, + "Request getRingtone error" + ) + + fun setCannedMessages(destNum: Int, messages: String) { + meshService?.setCannedMessages(destNum, messages) + } + + fun getCannedMessages(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getCannedMessages(packetId, dest) }, + "Request getCannedMessages error" + ) + + fun requestTraceroute(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestTraceroute(packetId, dest) }, + "Request traceroute error" + ) + fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { try { meshService?.requestPosition(destNum, position) @@ -325,10 +368,6 @@ class UIViewModel @Inject constructor( @Suppress("MemberVisibilityCanBePrivate") val isRouter: Boolean = config.device.role == Config.DeviceConfig.Role.ROUTER - // We consider hasWifi = ESP32 - fun hasGPS() = myNodeInfo.value?.hasGPS == true - fun hasWifi() = myNodeInfo.value?.hasWifi == true - /// hardware info about our local device (can be null) private val _myNodeInfo = MutableLiveData() val myNodeInfo: LiveData get() = _myNodeInfo @@ -361,93 +400,22 @@ class UIViewModel @Inject constructor( } } - inline fun updateDeviceConfig(crossinline body: (Config.DeviceConfig) -> Config.DeviceConfig) { - val data = body(config.device) - setConfig(config { device = data }) - } - - inline fun updatePositionConfig(crossinline body: (Config.PositionConfig) -> Config.PositionConfig) { - val data = body(config.position) - setConfig(config { position = data }) - } - - inline fun updatePowerConfig(crossinline body: (Config.PowerConfig) -> Config.PowerConfig) { - val data = body(config.power) - setConfig(config { power = data }) - } - - inline fun updateNetworkConfig(crossinline body: (Config.NetworkConfig) -> Config.NetworkConfig) { - val data = body(config.network) - setConfig(config { network = data }) - } - - inline fun updateDisplayConfig(crossinline body: (Config.DisplayConfig) -> Config.DisplayConfig) { - val data = body(config.display) - setConfig(config { display = data }) - } - - inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { + private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { val data = body(config.lora) setConfig(config { lora = data }) } - inline fun updateBluetoothConfig(crossinline body: (Config.BluetoothConfig) -> Config.BluetoothConfig) { - val data = body(config.bluetooth) - setConfig(config { bluetooth = data }) - } - // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { meshService?.setConfig(config.toByteArray()) } - inline fun updateMQTTConfig(crossinline body: (ModuleConfig.MQTTConfig) -> ModuleConfig.MQTTConfig) { - val data = body(module.mqtt) - setModuleConfig(moduleConfig { mqtt = data }) + fun setRemoteConfig(destNum: Int, config: Config) { + meshService?.setRemoteConfig(destNum, config.toByteArray()) } - inline fun updateSerialConfig(crossinline body: (ModuleConfig.SerialConfig) -> ModuleConfig.SerialConfig) { - val data = body(module.serial) - setModuleConfig(moduleConfig { serial = data }) - } - - inline fun updateExternalNotificationConfig(crossinline body: (ModuleConfig.ExternalNotificationConfig) -> ModuleConfig.ExternalNotificationConfig) { - val data = body(module.externalNotification) - setModuleConfig(moduleConfig { externalNotification = data }) - } - - inline fun updateStoreForwardConfig(crossinline body: (ModuleConfig.StoreForwardConfig) -> ModuleConfig.StoreForwardConfig) { - val data = body(module.storeForward) - setModuleConfig(moduleConfig { storeForward = data }) - } - - inline fun updateRangeTestConfig(crossinline body: (ModuleConfig.RangeTestConfig) -> ModuleConfig.RangeTestConfig) { - val data = body(module.rangeTest) - setModuleConfig(moduleConfig { rangeTest = data }) - } - - inline fun updateTelemetryConfig(crossinline body: (ModuleConfig.TelemetryConfig) -> ModuleConfig.TelemetryConfig) { - val data = body(module.telemetry) - setModuleConfig(moduleConfig { telemetry = data }) - } - - inline fun updateCannedMessageConfig(crossinline body: (ModuleConfig.CannedMessageConfig) -> ModuleConfig.CannedMessageConfig) { - val data = body(module.cannedMessage) - setModuleConfig(moduleConfig { cannedMessage = data }) - } - - inline fun updateAudioConfig(crossinline body: (ModuleConfig.AudioConfig) -> ModuleConfig.AudioConfig) { - val data = body(module.audio) - setModuleConfig(moduleConfig { audio = data }) - } - - inline fun updateRemoteHardwareConfig(crossinline body: (ModuleConfig.RemoteHardwareConfig) -> ModuleConfig.RemoteHardwareConfig) { - val data = body(module.remoteHardware) - setModuleConfig(moduleConfig { remoteHardware = data }) - } - - fun setModuleConfig(config: ModuleConfig) { - meshService?.setModuleConfig(config.toByteArray()) + fun setModuleConfig(destNum: Int, config: ModuleConfig) { + meshService?.setModuleConfig(destNum, config.toByteArray()) } /// Convert the channels array to and from [AppOnlyProtos.ChannelSet] @@ -518,6 +486,15 @@ class UIViewModel @Inject constructor( } } + fun setRemoteOwner(destNum: Int, user: User) { + try { + // Note: we use ?. here because we might be running in the emulator + meshService?.setRemoteOwner(destNum, user.toByteArray()) + } catch (ex: RemoteException) { + errormsg("Can't set username on device, is device offline? ${ex.message}") + } + } + val adminChannelIndex: Int get() = channelSet.settingsList.map { it.name.lowercase() }.indexOf("admin") @@ -584,7 +561,7 @@ class UIViewModel @Inject constructor( meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet -> // If we get a NodeInfo packet, use it to update our position data (if valid) packet.nodeInfo?.let { nodeInfo -> - positionToPos.invoke(nodeInfo.position)?.let { _ -> + positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position } } @@ -592,7 +569,7 @@ class UIViewModel @Inject constructor( packet.meshPacket?.let { proto -> // If the packet contains position data then use it to update, if valid packet.position?.let { position -> - positionToPos.invoke(position)?.let { _ -> + positionToPos.invoke(position)?.let { nodePositions[proto.from] = position } } 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 8ea1dd3e..bd184d99 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -481,8 +481,7 @@ class MeshService : Service(), Logging { * * If id is null we assume a broadcast message */ - private fun newMeshPacketTo(id: String) = - newMeshPacketTo(toNodeNum(id)) + private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id)) /** * Helper to make it easy to build a subpacket in the proper protobufs @@ -512,9 +511,11 @@ class MeshService : Service(), Logging { * Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildAdminPacket( + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one wantResponse: Boolean = false, initFn: AdminProtos.AdminMessage.Builder.() -> Unit ): MeshPacket = buildMeshPacket( + id = id, wantAck = true, channel = adminChannelIndex, priority = MeshPacket.Priority.RELIABLE @@ -1480,28 +1481,6 @@ class MeshService : Service(), Logging { } } - /** Send our current radio config to the device - */ - private fun setConfig(config: ConfigProtos.Config) { - if (deviceVersion < minDeviceVersion) return - debug("Setting new radio config!") - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - setConfig = config - }) - setLocalConfig(config) // Update our local copy - } - - /** Send our current module config to the device - */ - private fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) { - if (deviceVersion < minDeviceVersion) return - debug("Setting new module config!") - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { - setModuleConfig = config - }) - setLocalModuleConfig(config) // Update our local copy - } - /** * Send setOwner admin packet with [MeshProtos.User] protobuf */ @@ -1639,6 +1618,19 @@ class MeshService : Service(), Logging { this@MeshService.setOwner(user) } + override fun setRemoteOwner(destNum: Int, payload: ByteArray) = toRemoteExceptions { + val parsed = MeshProtos.User.parseFrom(payload) + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { + setOwner = parsed + }) + } + + override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getOwnerRequest = true + }) + } + override fun send(p: DataPacket) { toRemoteExceptions { if (p.id == 0) p.id = generatePacketId() @@ -1680,14 +1672,62 @@ class MeshService : Service(), Logging { this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() } + /** Send our current radio config to the device + */ override fun setConfig(payload: ByteArray) = toRemoteExceptions { - val parsed = ConfigProtos.Config.parseFrom(payload) - setConfig(parsed) + setRemoteConfig(myNodeNum, payload) } - override fun setModuleConfig(payload: ByteArray) = toRemoteExceptions { - val parsed = ModuleConfigProtos.ModuleConfig.parseFrom(payload) - setModuleConfig(parsed) + override fun setRemoteConfig(destNum: Int, payload: ByteArray) = toRemoteExceptions { + debug("Setting new radio config!") + val config = ConfigProtos.Config.parseFrom(payload) + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setConfig = config }) + if (destNum == myNodeNum) setLocalConfig(config) // Update our local copy + } + + override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getConfigRequestValue = config + }) + } + + /** Send our current module config to the device + */ + override fun setModuleConfig(destNum: Int, payload: ByteArray) = toRemoteExceptions { + debug("Setting new module config!") + val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { setModuleConfig = config }) + if (destNum == myNodeNum) setLocalModuleConfig(config) // Update our local copy + } + + override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getModuleConfigRequestValue = config + }) + } + + override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { + setRingtoneMessage = ringtone + }) + } + + override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getRingtoneRequest = true + }) + } + + override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket { + setCannedMessageModuleMessages = messages + }) + } + + override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { + getCannedMessageModuleMessagesRequest = true + }) } override fun setChannel(payload: ByteArray?) = toRemoteExceptions { @@ -1732,14 +1772,16 @@ class MeshService : Service(), Logging { stopLocationRequests() } - override fun requestPosition(idNum: Int, position: Position) = - toRemoteExceptions { - val (lat, lon, alt) = position + override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { + if (position == Position(0.0, 0.0, 0)) { // request position - if (idNum != 0) sendPosition(time = 1, destNum = idNum, wantResponse = true) - // set local node's fixed position - else sendPosition(time = 0, destNum = null, lat = lat, lon = lon, alt = alt) + sendPosition(time = 1, destNum = destNum, wantResponse = true) + } else { + // send fixed position + val (lat, lon, alt) = position + sendPosition(time = 0, destNum = null, lat = lat, lon = lon, alt = alt) } + } override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { sendToRadio(newMeshPacketTo(destNum).buildMeshPacket(id = requestId) { 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 62455c5c..41d1504a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -12,18 +13,23 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.twotone.KeyboardArrowRight 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.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.graphics.Color import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource @@ -33,15 +39,25 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable 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.ConfigProtos.Config +import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig +import com.geeksville.mesh.MeshProtos +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.config import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.moduleConfig import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.ui.components.PreferenceCategory +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 @@ -63,7 +79,7 @@ import com.google.accompanist.themeadapter.appcompat.AppCompatTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class DeviceSettingsFragment : ScreenFragment("Device Settings"), Logging { +class DeviceSettingsFragment(val node: NodeInfo) : ScreenFragment("Device Settings"), Logging { private val model: UIViewModel by activityViewModels() @@ -77,139 +93,240 @@ class DeviceSettingsFragment : ScreenFragment("Device Settings"), Logging { setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground)) setContent { AppCompatTheme { - RadioConfigNavHost(model) + RadioConfigNavHost(node, model) } } } } } -enum class ConfigDest(val title: String, val route: String) { - USER("User", "user"), - DEVICE("Device", "device"), - POSITION("Position", "position"), - POWER("Power", "power"), - NETWORK("Network", "network"), - DISPLAY("Display", "display"), - LORA("LoRa", "lora"), - BLUETOOTH("Bluetooth", "bluetooth") +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), + NETWORK("Network", "network", ConfigType.NETWORK_CONFIG), + DISPLAY("Display", "display", ConfigType.DISPLAY_CONFIG), + LORA("LoRa", "lora", ConfigType.LORA_CONFIG), + BLUETOOTH("Bluetooth", "bluetooth", ConfigType.BLUETOOTH_CONFIG); } -enum class ModuleDest(val title: String, val route: String) { - MQTT("MQTT", "mqtt"), - SERIAL("Serial", "serial"), - EXT_NOTIFICATION("External Notification", "ext_notification"), - STORE_FORWARD("Store & Forward", "store_forward"), - RANGE_TEST("Range Test", "range_test"), - TELEMETRY("Telemetry", "telemetry"), - CANNED_MESSAGE("Canned Message", "canned_message"), - AUDIO("Audio", "audio"), - REMOTE_HARDWARE("Remote Hardware", "remote_hardware") +enum class ModuleDest(val title: String, val route: String, val config: ModuleConfigType) { + MQTT("MQTT", "mqtt", ModuleConfigType.MQTT_CONFIG), + SERIAL("Serial", "serial", ModuleConfigType.SERIAL_CONFIG), + EXTERNAL_NOTIFICATION("External Notification", "ext_not", ModuleConfigType.EXTNOTIF_CONFIG), + STORE_FORWARD("Store & Forward", "store_forward", ModuleConfigType.STOREFORWARD_CONFIG), + RANGE_TEST("Range Test", "range_test", ModuleConfigType.RANGETEST_CONFIG), + TELEMETRY("Telemetry", "telemetry", ModuleConfigType.TELEMETRY_CONFIG), + CANNED_MESSAGE("Canned Message", "canned_message", ModuleConfigType.CANNEDMSG_CONFIG), + AUDIO("Audio", "audio", ModuleConfigType.AUDIO_CONFIG), + REMOTE_HARDWARE("Remote Hardware", "remote_hardware", ModuleConfigType.REMOTEHARDWARE_CONFIG); } @Composable -fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { +fun RadioConfigNavHost(node: NodeInfo, viewModel: UIViewModel = viewModel()) { val navController = rememberNavController() val focusManager = LocalFocusManager.current val connectionState by viewModel.connectionState.observeAsState() val connected = connectionState == MeshService.ConnectionState.CONNECTED - val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val localConfig by viewModel.localConfig.collectAsStateWithLifecycle() - val moduleConfig by viewModel.moduleConfig.collectAsStateWithLifecycle() + val destNum = node.num + + var userConfig by remember { mutableStateOf(MeshProtos.User.getDefaultInstance()) } + var radioConfig by remember { mutableStateOf(Config.getDefaultInstance()) } + var moduleConfig by remember { mutableStateOf(ModuleConfig.getDefaultInstance()) } + + var location by remember(node) { mutableStateOf(node.position) } + var ringtone by remember { mutableStateOf("") } + var cannedMessageMessages by remember { mutableStateOf("") } + + val configResponse by viewModel.packetResponse.collectAsStateWithLifecycle() + var isWaiting by remember { mutableStateOf(false) } + + LaunchedEffect(configResponse) { + val data = configResponse?.meshPacket?.decoded + if (isWaiting && data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) { + val parsed = AdminProtos.AdminMessage.parseFrom(data.payload) + when (parsed.payloadVariantCase) { + AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { + val response = parsed.getChannelResponse // TODO + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> { + isWaiting = false + userConfig = parsed.getOwnerResponse + navController.navigate("user") + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { + isWaiting = false + val response = parsed.getConfigResponse + radioConfig = response + enumValues().find { it.name == "${response.payloadVariantCase}" }?.let { + navController.navigate(it.route) + } + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> { + isWaiting = false + val response = parsed.getModuleConfigResponse + moduleConfig = response + enumValues().find { it.name == "${response.payloadVariantCase}" }?.let { + navController.navigate(it.route) + } + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> { + cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse + viewModel.getModuleConfig(destNum, ModuleConfigType.CANNEDMSG_CONFIG_VALUE) + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> { + ringtone = parsed.getRingtoneResponse + viewModel.getModuleConfig(destNum, ModuleConfigType.EXTNOTIF_CONFIG_VALUE) + } + else -> TODO() + } + } + } NavHost(navController = navController, startDestination = "home") { - composable("home") { RadioSettingsScreen(navController) } + composable("home") { + RadioSettingsScreen( + enabled = connected && !isWaiting, + headerText = node.user?.longName ?: stringResource(R.string.unknown_username), + onRouteClick = { configType -> + isWaiting = true + // clearAllConfigs() ? + when (configType) { + ConfigType.UNRECOGNIZED -> { + viewModel.getOwner(destNum) + } + is ConfigType -> { + viewModel.getConfig(destNum, configType.number) + } + ModuleConfigType.CANNEDMSG_CONFIG -> { + viewModel.getCannedMessages(destNum) + } + ModuleConfigType.EXTNOTIF_CONFIG -> { + viewModel.getRingtone(destNum) + } + is ModuleConfigType -> { + viewModel.getModuleConfig(destNum, configType.number) + } + } + }, + ) + } composable("user") { UserConfigItemList( - userConfig = ourNodeInfo?.user!!, + userConfig = userConfig, enabled = connected, focusManager = focusManager, onSaveClicked = { userInput -> focusManager.clearFocus() - viewModel.setOwner(userInput) + viewModel.setRemoteOwner(destNum, userInput) + userConfig = userInput } ) } composable("device") { DeviceConfigItemList( - deviceConfig = localConfig.device, + deviceConfig = radioConfig.device, enabled = connected, focusManager = focusManager, onSaveClicked = { deviceInput -> focusManager.clearFocus() - viewModel.updateDeviceConfig { deviceInput } + val config = config { device = deviceInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config } ) } composable("position") { PositionConfigItemList( - positionInfo = ourNodeInfo?.position, - positionConfig = localConfig.position, + locationInfo = location, + positionConfig = radioConfig.position, enabled = connected, focusManager = focusManager, onSaveClicked = { positionPair -> focusManager.clearFocus() val (locationInput, positionInput) = positionPair - if (locationInput != ourNodeInfo?.position && positionInput.fixedPosition) - locationInput?.let { viewModel.requestPosition(0, it) } - if (positionInput != localConfig.position) viewModel.updatePositionConfig { positionInput } + if (locationInput != node.position && positionInput.fixedPosition) { + locationInput?.let { viewModel.requestPosition(destNum, it) } + location = locationInput + } + if (positionInput != radioConfig.position) { + val config = config { position = positionInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config + } } ) } composable("power") { PowerConfigItemList( - powerConfig = localConfig.power, + powerConfig = radioConfig.power, enabled = connected, focusManager = focusManager, onSaveClicked = { powerInput -> focusManager.clearFocus() - viewModel.updatePowerConfig { powerInput } + val config = config { power = powerInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config } ) } composable("network") { NetworkConfigItemList( - networkConfig = localConfig.network, + networkConfig = radioConfig.network, enabled = connected, focusManager = focusManager, onSaveClicked = { networkInput -> focusManager.clearFocus() - viewModel.updateNetworkConfig { networkInput } + val config = config { network = networkInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config } ) } composable("display") { DisplayConfigItemList( - displayConfig = localConfig.display, + displayConfig = radioConfig.display, enabled = connected, focusManager = focusManager, onSaveClicked = { displayInput -> focusManager.clearFocus() - viewModel.updateDisplayConfig { displayInput } + val config = config { display = displayInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config } ) } composable("lora") { LoRaConfigItemList( - loraConfig = localConfig.lora, + loraConfig = radioConfig.lora, enabled = connected, focusManager = focusManager, onSaveClicked = { loraInput -> focusManager.clearFocus() - viewModel.updateLoraConfig { loraInput } + val config = config { lora = loraInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config } ) } composable("bluetooth") { BluetoothConfigItemList( - bluetoothConfig = localConfig.bluetooth, + bluetoothConfig = radioConfig.bluetooth, enabled = connected, focusManager = focusManager, onSaveClicked = { bluetoothInput -> focusManager.clearFocus() - viewModel.updateBluetoothConfig { bluetoothInput } + val config = config { bluetooth = bluetoothInput } + viewModel.setRemoteConfig(destNum, config) + radioConfig = config } ) } @@ -220,7 +337,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { mqttInput -> focusManager.clearFocus() - viewModel.updateMQTTConfig { mqttInput } + val config = moduleConfig { mqtt = mqttInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } @@ -231,18 +350,30 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { serialInput -> focusManager.clearFocus() - viewModel.updateSerialConfig { serialInput } + val config = moduleConfig { serial = serialInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } - composable("ext_notification") { + composable("ext_not") { ExternalNotificationConfigItemList( - externalNotificationConfig = moduleConfig.externalNotification, + ringtone = ringtone, + extNotificationConfig = moduleConfig.externalNotification, enabled = connected, focusManager = focusManager, - onSaveClicked = { externalNotificationInput -> + onSaveClicked = { extNotificationPair -> focusManager.clearFocus() - viewModel.updateExternalNotificationConfig { externalNotificationInput } + val (ringtoneInput, extNotificationInput) = extNotificationPair + if (ringtoneInput != ringtone) { + viewModel.setRingtone(destNum, ringtoneInput) + ringtone = ringtoneInput + } + if (extNotificationInput != moduleConfig.externalNotification) { + val config = moduleConfig { externalNotification = extNotificationInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config + } } ) } @@ -253,7 +384,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { storeForwardInput -> focusManager.clearFocus() - viewModel.updateStoreForwardConfig { storeForwardInput } + val config = moduleConfig { storeForward = storeForwardInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } @@ -264,7 +397,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { rangeTestInput -> focusManager.clearFocus() - viewModel.updateRangeTestConfig { rangeTestInput } + val config = moduleConfig { rangeTest = rangeTestInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } @@ -275,18 +410,30 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { telemetryInput -> focusManager.clearFocus() - viewModel.updateTelemetryConfig { telemetryInput } + val config = moduleConfig { telemetry = telemetryInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } composable("canned_message") { CannedMessageConfigItemList( + messages = cannedMessageMessages, cannedMessageConfig = moduleConfig.cannedMessage, enabled = connected, focusManager = focusManager, - onSaveClicked = { cannedMessageInput -> + onSaveClicked = { cannedMessagePair -> focusManager.clearFocus() - viewModel.updateCannedMessageConfig { cannedMessageInput } + val (messagesInput, cannedMessageInput) = cannedMessagePair + if (messagesInput != cannedMessageMessages) { + viewModel.setCannedMessages(destNum, messagesInput) + cannedMessageMessages = messagesInput + } + if (cannedMessageInput != moduleConfig.cannedMessage) { + val config = moduleConfig { cannedMessage = cannedMessageInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config + } } ) } @@ -297,7 +444,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { audioInput -> focusManager.clearFocus() - viewModel.updateAudioConfig { audioInput } + val config = moduleConfig { audio = audioInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } @@ -308,7 +457,9 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { focusManager = focusManager, onSaveClicked = { remoteHardwareInput -> focusManager.clearFocus() - viewModel.updateRemoteHardwareConfig { remoteHardwareInput } + val config = moduleConfig { remoteHardware = remoteHardwareInput } + viewModel.setModuleConfig(destNum, config) + moduleConfig = config } ) } @@ -316,12 +467,16 @@ fun RadioConfigNavHost(viewModel: UIViewModel = viewModel()) { } @Composable -fun NavCard(title: String, onClick: () -> Unit) { +fun NavCard( + title: String, + enabled: Boolean, + onClick: () -> Unit +) { Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 2.dp, horizontal = 16.dp) - .clickable { onClick() }, + .padding(vertical = 2.dp) + .clickable(enabled = enabled) { onClick() }, elevation = 4.dp ) { Row( @@ -331,6 +486,7 @@ fun NavCard(title: String, onClick: () -> Unit) { Text( text = title, style = MaterialTheme.typography.body1, + color = if (!enabled) MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled) else Color.Unspecified, modifier = Modifier.weight(1f) ) Icon( @@ -341,32 +497,43 @@ fun NavCard(title: String, onClick: () -> Unit) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable -fun RadioSettingsScreen(navController: NavHostController) { - LazyColumn { - item { - PreferenceCategory( - stringResource(id = R.string.device_settings), Modifier.padding(horizontal = 16.dp) - ) - } - items(ConfigDest.values()) { configs -> - NavCard(configs.title) { navController.navigate(configs.route) } +fun RadioSettingsScreen( + enabled: Boolean = true, + headerText: String = "longName", + onRouteClick: (Any) -> Unit = {}, +) { + LazyColumn( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + stickyHeader { + TextDividerPreference(headerText) } item { PreferenceCategory( - stringResource(id = R.string.module_settings), Modifier.padding(horizontal = 16.dp) + stringResource(id = R.string.device_settings) + ) + } + items(ConfigDest.values()) { configs -> + NavCard(configs.title, enabled = enabled) { onRouteClick(configs.config) } + } + + item { + PreferenceCategory( + stringResource(id = R.string.module_settings) ) } items(ModuleDest.values()) { modules -> - NavCard(modules.title) { navController.navigate(modules.route) } + NavCard(modules.title, enabled = enabled) { onRouteClick(modules.config) } } } } @Preview(showBackground = true) @Composable -fun RadioSettingsScreenPreview(){ - RadioSettingsScreen(NavHostController(LocalContext.current)) +fun RadioSettingsScreenPreview() { + RadioSettingsScreen() } diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index b769987c..6819d75b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -17,6 +17,7 @@ import androidx.lifecycle.asLiveData import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView 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.databinding.AdapterNodeLayoutBinding @@ -56,6 +57,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { private var nodes = arrayOf() private fun popup(view: View, position: Int) { + if (!model.isConnected()) return val node = nodes[position] val user = node.user val showAdmin = position == 0 || model.adminChannelIndex > 0 @@ -93,6 +95,13 @@ class UsersFragment : ScreenFragment("Users"), Logging { model.requestTraceroute(node.num) } } + R.id.remote_admin -> { + debug("calling remote admin --> destNum: ${node.num}") + parentFragmentManager.beginTransaction() + .replace(R.id.mainActivityLayout, DeviceSettingsFragment(node)) + .addToBackStack(null) + .commit() + } R.id.reboot -> { MaterialAlertDialogBuilder(requireContext()) .setTitle("${getString(R.string.reboot)}\n${user?.longName}?") @@ -333,6 +342,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { model.packetResponse.asLiveData().observe(viewLifecycleOwner) { meshLog -> meshLog?.meshPacket?.let { meshPacket -> + if (meshPacket.decoded.portnum != Portnums.PortNum.TRACEROUTE_APP) return@let val routeList = meshLog.routeDiscovery?.routeList fun nodeName(num: Int) = model.nodeDB.nodesByNum?.get(num)?.user?.longName diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt index ae22b845..b90a6c0a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/CannedMessageConfigItemList.kt @@ -26,11 +26,13 @@ import com.geeksville.mesh.ui.components.SwitchPreference @Composable fun CannedMessageConfigItemList( + messages: String, cannedMessageConfig: CannedMessageConfig, enabled: Boolean, focusManager: FocusManager, - onSaveClicked: (CannedMessageConfig) -> Unit, + onSaveClicked: (Pair) -> Unit, ) { + var messagesInput by remember(messages) { mutableStateOf(messages) } var cannedMessageInput by remember(cannedMessageConfig) { mutableStateOf(cannedMessageConfig) } LazyColumn( @@ -162,14 +164,29 @@ fun CannedMessageConfigItemList( } item { Divider() } + item { + EditTextPreference(title = "Messages", + value = messagesInput, + maxSize = 200, // messages max_size:201 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { messagesInput = it } + ) + } + item { PreferenceFooter( - enabled = cannedMessageInput != cannedMessageConfig, + enabled = cannedMessageInput != cannedMessageConfig || messagesInput != messages, onCancelClicked = { focusManager.clearFocus() + messagesInput = messages cannedMessageInput = cannedMessageConfig }, - onSaveClicked = { onSaveClicked(cannedMessageInput) } + onSaveClicked = { onSaveClicked(Pair(messagesInput,cannedMessageInput)) } ) } } @@ -179,6 +196,7 @@ fun CannedMessageConfigItemList( @Composable fun CannedMessageConfigPreview(){ CannedMessageConfigItemList( + messages = "", cannedMessageConfig = CannedMessageConfig.getDefaultInstance(), enabled = true, focusManager = LocalFocusManager.current, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt index 24fbe86f..a8cc5b2c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/ExternalNotificationConfigItemList.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh.ui.components.config import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -12,6 +13,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.ExternalNotificationConfig import com.geeksville.mesh.copy @@ -23,13 +26,15 @@ import com.geeksville.mesh.ui.components.TextDividerPreference @Composable fun ExternalNotificationConfigItemList( - externalNotificationConfig: ExternalNotificationConfig, + ringtone: String, + extNotificationConfig: ExternalNotificationConfig, enabled: Boolean, focusManager: FocusManager, - onSaveClicked: (ExternalNotificationConfig) -> Unit, + onSaveClicked: (Pair) -> Unit, ) { - var externalNotificationInput by remember(externalNotificationConfig) { - mutableStateOf(externalNotificationConfig) + var ringtoneInput by remember(ringtone) { mutableStateOf(ringtone) } + var externalNotificationInput by remember(extNotificationConfig) { + mutableStateOf(extNotificationConfig) } LazyColumn( @@ -183,14 +188,29 @@ fun ExternalNotificationConfigItemList( }) } + item { + EditTextPreference(title = "Ringtone", + value = ringtoneInput, + maxSize = 230, // ringtone max_size:231 + enabled = enabled, + isError = false, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { ringtoneInput = it } + ) + } + item { PreferenceFooter( - enabled = externalNotificationInput != externalNotificationConfig, + enabled = externalNotificationInput != extNotificationConfig || ringtoneInput != ringtone, onCancelClicked = { focusManager.clearFocus() - externalNotificationInput = externalNotificationConfig + ringtoneInput = ringtone + externalNotificationInput = extNotificationConfig }, - onSaveClicked = { onSaveClicked(externalNotificationInput) } + onSaveClicked = { onSaveClicked(Pair(ringtoneInput, externalNotificationInput)) } ) } } @@ -200,7 +220,8 @@ fun ExternalNotificationConfigItemList( @Composable fun ExternalNotificationConfigPreview(){ ExternalNotificationConfigItemList( - externalNotificationConfig = ExternalNotificationConfig.getDefaultInstance(), + ringtone = "", + extNotificationConfig = ExternalNotificationConfig.getDefaultInstance(), enabled = true, focusManager = LocalFocusManager.current, onSaveClicked = { }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt index 06f0b275..79f59d9c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/PositionConfigItemList.kt @@ -25,13 +25,13 @@ import com.geeksville.mesh.ui.components.SwitchPreference @Composable fun PositionConfigItemList( - positionInfo: Position?, + locationInfo: Position?, positionConfig: PositionConfig, enabled: Boolean, focusManager: FocusManager, onSaveClicked: (Pair) -> Unit, ) { - var locationInput by remember(positionInfo) { mutableStateOf(positionInfo) } + var locationInput by remember(locationInfo) { mutableStateOf(locationInfo) } var positionInput by remember(positionConfig) { mutableStateOf(positionConfig) } LazyColumn( @@ -175,10 +175,10 @@ fun PositionConfigItemList( item { PreferenceFooter( - enabled = positionInput != positionConfig || locationInput != positionInfo, + enabled = positionInput != positionConfig || locationInput != locationInfo, onCancelClicked = { focusManager.clearFocus() - locationInput = positionInfo + locationInput = locationInfo positionInput = positionConfig }, onSaveClicked = { onSaveClicked(Pair(locationInput, positionInput)) } @@ -191,7 +191,7 @@ fun PositionConfigItemList( @Composable fun PositionConfigPreview(){ PositionConfigItemList( - positionInfo = null, + locationInfo = null, positionConfig = PositionConfig.getDefaultInstance(), enabled = true, focusManager = LocalFocusManager.current, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt index cf1f5f2e..05be9fba 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/config/UserConfigItemList.kt @@ -17,20 +17,21 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.copy import com.geeksville.mesh.model.getInitials import com.geeksville.mesh.ui.components.EditTextPreference import com.geeksville.mesh.ui.components.PreferenceCategory import com.geeksville.mesh.ui.components.PreferenceFooter import com.geeksville.mesh.ui.components.RegularPreference import com.geeksville.mesh.ui.components.SwitchPreference +import com.geeksville.mesh.user @Composable fun UserConfigItemList( - userConfig: MeshUser, + userConfig: MeshProtos.User, enabled: Boolean, focusManager: FocusManager, - onSaveClicked: (MeshUser) -> Unit, + onSaveClicked: (MeshProtos.User) -> Unit, ) { var userInput by remember(userConfig) { mutableStateOf(userConfig) } @@ -57,9 +58,9 @@ fun UserConfigItemList( ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), onValueChanged = { - userInput = userInput.copy(longName = it) + userInput = userInput.copy { longName = it } if (getInitials(it).toByteArray().size <= 4) // short_name max_size:5 - userInput = userInput.copy(shortName = getInitials(it)) + userInput = userInput.copy { shortName = getInitials(it) } }) } @@ -73,7 +74,7 @@ fun UserConfigItemList( keyboardType = KeyboardType.Text, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { userInput = userInput.copy(shortName = it) }) + onValueChanged = { userInput = userInput.copy { shortName = it } }) } item { @@ -87,7 +88,7 @@ fun UserConfigItemList( SwitchPreference(title = "Licensed amateur radio", checked = userInput.isLicensed, enabled = enabled, - onCheckedChange = { userInput = userInput.copy(isLicensed = it) }) + onCheckedChange = { userInput = userInput.copy { isLicensed = it } }) } item { Divider() } @@ -107,13 +108,13 @@ fun UserConfigItemList( @Composable fun UserConfigPreview(){ UserConfigItemList( - userConfig = MeshUser( - id = "!a280d9c8", - longName = "Meshtastic d9c8", - shortName = "d9c8", - hwModel = MeshProtos.HardwareModel.RAK4631, + userConfig = user { + id = "!a280d9c8" + longName = "Meshtastic d9c8" + shortName = "d9c8" + hwModel = MeshProtos.HardwareModel.RAK4631 isLicensed = false - ), + }, enabled = true, focusManager = LocalFocusManager.current, onSaveClicked = { }, diff --git a/app/src/main/res/menu/menu_nodes.xml b/app/src/main/res/menu/menu_nodes.xml index ab4d7100..198655b3 100644 --- a/app/src/main/res/menu/menu_nodes.xml +++ b/app/src/main/res/menu/menu_nodes.xml @@ -16,6 +16,10 @@ app:showAsAction="withText" /> + Start Download Request position Close - Device settings - Module settings + Radio configuration + Module configuration Add Calculating… Offline Manager