From 3288b07e5ec1b2c5a434421d9f112edd3fff07e8 Mon Sep 17 00:00:00 2001 From: andrekir Date: Fri, 6 Oct 2023 18:38:06 -0300 Subject: [PATCH] refactor: move RadioConfig logic into separate ViewModel --- app/build.gradle | 2 +- .../mesh/model/RadioConfigViewModel.kt | 512 ++++++++++++++++++ .../java/com/geeksville/mesh/model/UIState.kt | 455 ++-------------- .../datastore/RadioConfigRepository.kt | 26 +- .../mesh/ui/DeviceSettingsFragment.kt | 37 +- .../geeksville/mesh/ui/SettingsFragment.kt | 2 +- 6 files changed, 599 insertions(+), 435 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index ea93d5c82..ca0c397eb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -162,7 +162,7 @@ dependencies { // Hilt implementation "com.google.dagger:hilt-android:$hilt_version" - // implementation "androidx.hilt:hilt-navigation-compose:1.0.0" + implementation "androidx.hilt:hilt-navigation-compose:1.0.0" ksp "com.google.dagger:hilt-compiler:$hilt_version" // Navigation diff --git a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt new file mode 100644 index 000000000..be2f45bd0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt @@ -0,0 +1,512 @@ +package com.geeksville.mesh.model + +import android.app.Application +import android.net.Uri +import android.os.RemoteException +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.AdminProtos +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.IMeshService +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.MyNodeInfo +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.Portnums +import com.geeksville.mesh.Position +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.config +import com.geeksville.mesh.database.MeshLogRepository +import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.deviceProfile +import com.geeksville.mesh.moduleConfig +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.geeksville.mesh.service.MeshService.ConnectionState +import com.geeksville.mesh.ui.ConfigRoute +import com.geeksville.mesh.ui.ResponseState +import com.google.protobuf.MessageLite +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileOutputStream +import javax.inject.Inject + +/** + * Data class that represents the current RadioConfig state. + */ +data class RadioConfigState( + val route: String = "", + val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(), + val channelList: List = emptyList(), + val radioConfig: ConfigProtos.Config = ConfigProtos.Config.getDefaultInstance(), + val moduleConfig: ModuleConfigProtos.ModuleConfig = ModuleConfigProtos.ModuleConfig.getDefaultInstance(), + val ringtone: String = "", + val cannedMessageMessages: String = "", + val responseState: ResponseState = ResponseState.Empty, +) + +@HiltViewModel +class RadioConfigViewModel @Inject constructor( + private val app: Application, + radioInterfaceService: RadioInterfaceService, + private val radioConfigRepository: RadioConfigRepository, + meshLogRepository: MeshLogRepository, +) : ViewModel(), Logging { + + private var destNum: Int = 0 + private val meshService: IMeshService? get() = radioConfigRepository.meshService + + // Connection state to our radio device + private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + val connectionState: StateFlow get() = _connectionState + + // A map from nodeNum to NodeInfo + private val _nodes = MutableStateFlow>(mapOf()) + val nodes: StateFlow> = _nodes + + private val _myNodeInfo = MutableStateFlow(null) + val myNodeInfo get() = _myNodeInfo + + private val requestIds = MutableStateFlow>(hashMapOf()) + private val _radioConfigState = MutableStateFlow(RadioConfigState()) + val radioConfigState: StateFlow = _radioConfigState + + private val _currentDeviceProfile = MutableStateFlow(deviceProfile {}) + val currentDeviceProfile get() = _currentDeviceProfile.value + + init { + radioInterfaceService.connectionState.onEach { state -> + _connectionState.value = when { + state.isConnected -> ConnectionState.CONNECTED + else -> ConnectionState.DISCONNECTED + } + }.launchIn(viewModelScope) + + radioConfigRepository.myNodeInfoFlow().onEach { + _myNodeInfo.value = it + }.launchIn(viewModelScope) + + radioConfigRepository.nodeInfoFlow().onEach { list -> + _nodes.value = list.associateBy { it.num } + }.launchIn(viewModelScope) + + radioConfigRepository.deviceProfileFlow.onEach { + _currentDeviceProfile.value = it + }.launchIn(viewModelScope) + + viewModelScope.launch { + combine(meshLogRepository.getAllLogs(9), requestIds) { list, ids -> + val unprocessed = ids.filterValues { !it }.keys.ifEmpty { return@combine emptyList() } + list.filter { log -> log.meshPacket?.decoded?.requestId in unprocessed } + }.collect { it.forEach(::processPacketResponse) } + } + debug("RadioConfigViewModel created") + } + + val myNodeNum get() = _myNodeInfo.value?.myNodeNum + val maxChannels get() = _myNodeInfo.value?.maxChannels ?: 8 + private val ourNodeInfo: NodeInfo? get() = nodes.value[myNodeNum] + + override fun onCleared() { + super.onCleared() + debug("RadioConfigViewModel cleared") + } + + private fun request( + destNum: Int, + requestAction: suspend (IMeshService, Int, Int) -> Unit, + errorMessage: String, + ) = viewModelScope.launch { + meshService?.let { service -> + this@RadioConfigViewModel.destNum = destNum + val packetId = service.packetId + try { + requestAction(service, packetId, destNum) + requestIds.update { it.apply { put(packetId, false) } } + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + val total = maxOf(requestIds.value.size, state.responseState.total) + state.copy(responseState = state.responseState.copy(total = total)) + } else { + state.copy(responseState = ResponseState.Loading()) + } + } + } catch (ex: RemoteException) { + errormsg("$errorMessage: ${ex.message}") + } + } + } + + fun setOwner(user: MeshProtos.User) { + setRemoteOwner(myNodeNum ?: return, user) + } + + fun setRemoteOwner(destNum: Int, user: MeshProtos.User) = request( + destNum, + { service, packetId, _ -> + _radioConfigState.update { it.copy(userConfig = user) } + service.setRemoteOwner(packetId, user.toByteArray()) + }, + "Request setOwner error", + ) + + fun getOwner(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteOwner(packetId, dest) }, + "Request getOwner error" + ) + + fun updateChannels( + destNum: Int, + new: List, + old: List, + ) { + getChannelList(new, old).forEach { setRemoteChannel(destNum, it) } + + if (destNum == myNodeNum) viewModelScope.launch { + radioConfigRepository.replaceAllSettings(new) + } + _radioConfigState.update { it.copy(channelList = new) } + } + + private fun setChannels(channelUrl: String) = viewModelScope.launch { + val new = ChannelSet(Uri.parse(channelUrl)).protobuf + val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch + updateChannels(myNodeNum ?: return@launch, new.settingsList, old.settingsList) + } + + private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) = request( + destNum, + { service, packetId, dest -> + service.setRemoteChannel(packetId, dest, channel.toByteArray()) + }, + "Request setRemoteChannel error" + ) + + fun getChannel(destNum: Int, index: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) }, + "Request getChannel error" + ) + + fun setRemoteConfig(destNum: Int, config: ConfigProtos.Config) = request( + destNum, + { service, packetId, dest -> + _radioConfigState.update { it.copy(radioConfig = config) } + service.setRemoteConfig(packetId, dest, config.toByteArray()) + }, + "Request setConfig error", + ) + + fun getConfig(destNum: Int, configType: Int) = request( + destNum, + { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) }, + "Request getConfig error", + ) + + fun setModuleConfig(destNum: Int, config: ModuleConfigProtos.ModuleConfig) = request( + destNum, + { service, packetId, dest -> + _radioConfigState.update { it.copy(moduleConfig = config) } + service.setModuleConfig(packetId, dest, config.toByteArray()) + }, + "Request setConfig error", + ) + + fun getModuleConfig(destNum: Int, configType: Int) = request( + destNum, + { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) }, + "Request getModuleConfig error", + ) + + fun setRingtone(destNum: Int, ringtone: String) { + _radioConfigState.update { it.copy(ringtone = ringtone) } + 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) { + _radioConfigState.update { it.copy(cannedMessageMessages = messages) } + meshService?.setCannedMessages(destNum, messages) + } + + fun getCannedMessages(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.getCannedMessages(packetId, dest) }, + "Request getCannedMessages error" + ) + + fun requestShutdown(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestShutdown(packetId, dest) }, + "Request shutdown error" + ) + + fun requestReboot(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestReboot(packetId, dest) }, + "Request reboot error" + ) + + fun requestFactoryReset(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestFactoryReset(packetId, dest) }, + "Request factory reset error" + ) + + fun requestNodedbReset(destNum: Int) = request( + destNum, + { service, packetId, dest -> service.requestNodedbReset(packetId, dest) }, + "Request NodeDB reset error" + ) + + fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { + try { + meshService?.requestPosition(destNum, position) + } catch (ex: RemoteException) { + errormsg("Request position error: ${ex.message}") + } + } + + // Set the radio config (also updates our saved copy in preferences) + fun setConfig(config: ConfigProtos.Config) { + setRemoteConfig(myNodeNum ?: return, config) + } + + fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) { + setModuleConfig(myNodeNum ?: return, config) + } + + private val _deviceProfile = MutableStateFlow(null) + val deviceProfile get() = _deviceProfile.value + + fun setDeviceProfile(deviceProfile: DeviceProfile?) { + _deviceProfile.value = deviceProfile + } + + fun importProfile(uri: Uri) = viewModelScope.launch(Dispatchers.IO) { + try { + app.contentResolver.openInputStream(uri).use { inputStream -> + val bytes = inputStream?.readBytes() + val protobuf = DeviceProfile.parseFrom(bytes) + _deviceProfile.value = protobuf + } + } catch (ex: Exception) { + val error = "${ex.javaClass.simpleName}: ${ex.message}" + errormsg("Import DeviceProfile error: ${ex.message}") + setResponseStateError(error) + } + } + + fun exportProfile(uri: Uri) = viewModelScope.launch { + val profile = deviceProfile ?: return@launch + writeToUri(uri, profile) + _deviceProfile.value = null + } + + private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) { + try { + app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> + FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream -> + message.writeTo(outputStream) + } + } + setResponseStateSuccess() + } catch (ex: Exception) { + val error = "${ex.javaClass.simpleName}: ${ex.message}" + errormsg("Can't write file error: ${ex.message}") + setResponseStateError(error) + } + } + + fun installProfile(protobuf: DeviceProfile) = with(protobuf) { + _deviceProfile.value = null + // meshService?.beginEditSettings() + if (hasLongName() || hasShortName()) ourNodeInfo?.user?.let { + val user = it.copy( + longName = if (hasLongName()) longName else it.longName, + shortName = if (hasShortName()) shortName else it.shortName + ) + setOwner(user.toProto()) + } + if (hasChannelUrl()) { + setChannels(channelUrl) + } + if (hasConfig()) { + setConfig(config { device = config.device }) + setConfig(config { position = config.position }) + setConfig(config { power = config.power }) + setConfig(config { network = config.network }) + setConfig(config { display = config.display }) + setConfig(config { lora = config.lora }) + setConfig(config { bluetooth = config.bluetooth }) + } + if (hasModuleConfig()) moduleConfig.let { + setModuleConfig(moduleConfig { mqtt = it.mqtt }) + setModuleConfig(moduleConfig { serial = it.serial }) + setModuleConfig(moduleConfig { externalNotification = it.externalNotification }) + setModuleConfig(moduleConfig { storeForward = it.storeForward }) + setModuleConfig(moduleConfig { rangeTest = it.rangeTest }) + setModuleConfig(moduleConfig { telemetry = it.telemetry }) + setModuleConfig(moduleConfig { cannedMessage = it.cannedMessage }) + setModuleConfig(moduleConfig { audio = it.audio }) + setModuleConfig(moduleConfig { remoteHardware = it.remoteHardware }) + } + setResponseStateSuccess() + // meshService?.commitEditSettings() + } + + fun clearPacketResponse() { + requestIds.value = hashMapOf() + _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } + } + + fun setResponseStateLoading(route: String) { + _radioConfigState.value = RadioConfigState( + route = route, + responseState = ResponseState.Loading(), + ) + // channel editor is synchronous, so we don't use requestIds as total + if (route == ConfigRoute.CHANNELS.name) setResponseStateTotal(maxChannels + 1) + } + + private fun setResponseStateTotal(total: Int) { + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + state.copy(responseState = state.responseState.copy(total = total)) + } else { + state // Return the unchanged state for other response states + } + } + } + + private fun setResponseStateSuccess() { + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + state.copy(responseState = ResponseState.Success(true)) + } else { + state // Return the unchanged state for other response states + } + } + } + + private fun setResponseStateError(error: String) { + _radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) } + } + + private fun incrementCompleted() { + _radioConfigState.update { state -> + if (state.responseState is ResponseState.Loading) { + val increment = state.responseState.completed + 1 + state.copy(responseState = state.responseState.copy(completed = increment)) + } else { + state // Return the unchanged state for other response states + } + } + } + + private fun processPacketResponse(log: MeshLog?) { + val packet = log?.meshPacket ?: return + val data = packet.decoded + requestIds.update { it.apply { put(data.requestId, true) } } + + // val destNum = destNode.value?.num ?: return + val debugMsg = + "requestId: ${data.requestId.toUInt()} to: ${destNum.toUInt()} received %s from: ${packet.from.toUInt()}" + + if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) { + val parsed = MeshProtos.Routing.parseFrom(data.payload) + debug(debugMsg.format(parsed.errorReason.name)) + if (parsed.errorReason != MeshProtos.Routing.Error.NONE) { + setResponseStateError(parsed.errorReason.name) + } else if (packet.from == destNum) { + if (requestIds.value.filterValues { !it }.isEmpty()) setResponseStateSuccess() + else incrementCompleted() + } + } + if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) { + val parsed = AdminProtos.AdminMessage.parseFrom(data.payload) + debug(debugMsg.format(parsed.payloadVariantCase.name)) + if (destNum != packet.from) { + setResponseStateError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.") + return + } + // check if destination is channel editor + val goChannels = radioConfigState.value.route == ConfigRoute.CHANNELS.name + when (parsed.payloadVariantCase) { + AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { + val response = parsed.getChannelResponse + // Stop once we get to the first disabled entry + if (response.role != ChannelProtos.Channel.Role.DISABLED) { + _radioConfigState.update { state -> + state.copy(channelList = state.channelList.toMutableList().apply { + add(response.index, response.settings) + }) + } + incrementCompleted() + if (response.index + 1 < maxChannels && goChannels) { + // Not done yet, request next channel + getChannel(destNum, response.index + 1) + } + } else { + // Received last channel, update total and start channel editor + setResponseStateTotal(response.index + 1) + } + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> { + _radioConfigState.update { it.copy(userConfig = parsed.getOwnerResponse) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { + val response = parsed.getConfigResponse + if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET + setResponseStateError(response.payloadVariantCase.name) + } + _radioConfigState.update { it.copy(radioConfig = response) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> { + val response = parsed.getModuleConfigResponse + if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET + setResponseStateError(response.payloadVariantCase.name) + } + _radioConfigState.update { it.copy(moduleConfig = response) } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> { + _radioConfigState.update { + it.copy(cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse) + } + incrementCompleted() + } + + AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> { + _radioConfigState.update { it.copy(ringtone = parsed.getRingtoneResponse) } + incrementCompleted() + } + + else -> TODO() + } + } + } +} 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 afefa74d5..d72fdd7d7 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -15,9 +15,7 @@ import androidx.lifecycle.viewModelScope import com.geeksville.mesh.android.Logging import com.geeksville.mesh.* import com.geeksville.mesh.ChannelProtos.ChannelSettings -import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile import com.geeksville.mesh.ConfigProtos.Config -import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.QuickChatActionRepository import com.geeksville.mesh.database.entity.Packet @@ -25,15 +23,11 @@ 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.RadioConfigRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.service.MeshService -import com.geeksville.mesh.ui.ConfigRoute -import com.geeksville.mesh.ui.ResponseState import com.geeksville.mesh.util.positionToMeter -import com.google.protobuf.MessageLite import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -50,7 +44,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedWriter import java.io.FileNotFoundException -import java.io.FileOutputStream import java.io.FileWriter import java.text.SimpleDateFormat import java.util.Locale @@ -81,18 +74,29 @@ fun getInitials(nameIn: String): String { } /** - * Data class that represents the current RadioConfig state. + * Builds a [Channel] list from the difference between two [ChannelSettings] lists. + * Only changes are included in the resulting list. + * + * @param new The updated [ChannelSettings] list. + * @param old The current [ChannelSettings] list (required to disable unused channels). + * @return A [Channel] list containing only the modified channels. */ -data class RadioConfigState( - val route: String = "", - val userConfig: User = User.getDefaultInstance(), - val channelList: List = emptyList(), - val radioConfig: Config = Config.getDefaultInstance(), - val moduleConfig: ModuleConfig = ModuleConfig.getDefaultInstance(), - val ringtone: String = "", - val cannedMessageMessages: String = "", - val responseState: ResponseState = ResponseState.Empty, -) +internal fun getChannelList( + new: List, + old: List, +): List = buildList { + for (i in 0..maxOf(old.lastIndex, new.lastIndex)) { + if (old.getOrNull(i) != new.getOrNull(i)) add(channel { + role = when (i) { + 0 -> ChannelProtos.Channel.Role.PRIMARY + in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY + else -> ChannelProtos.Channel.Role.DISABLED + } + index = i + settings = new.getOrNull(i) ?: channelSettings { } + }) + } +} @HiltViewModel class UIViewModel @Inject constructor( @@ -136,8 +140,6 @@ class UIViewModel @Inject constructor( val ourNodeInfo: StateFlow = _ourNodeInfo private val requestIds = MutableStateFlow>(hashMapOf()) - private val _radioConfigState = MutableStateFlow(RadioConfigState()) - val radioConfigState: StateFlow = _radioConfigState init { radioConfigRepository.nodeInfoFlow().onEach(nodeDB::setNodes) @@ -246,132 +248,16 @@ class UIViewModel @Inject constructor( } } - private fun request( - destNum: Int, - requestAction: suspend (IMeshService, Int, Int) -> Unit, - errorMessage: String, - ) = viewModelScope.launch { - meshService?.let { service -> - val packetId = service.packetId - try { - requestAction(service, packetId, destNum) - requestIds.update { it.apply { put(packetId, false) } } - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - val total = maxOf(requestIds.value.size, state.responseState.total) - state.copy(responseState = state.responseState.copy(total = total)) - } else { - state.copy(responseState = ResponseState.Loading()) - } - } - } catch (ex: RemoteException) { - errormsg("$errorMessage: ${ex.message}") - } + fun requestTraceroute(destNum: Int) { + try { + val packetId = meshService?.packetId ?: return + meshService?.requestTraceroute(packetId, destNum) + requestIds.update { it.apply { put(packetId, false) } } + } catch (ex: RemoteException) { + errormsg("Request traceroute error: ${ex.message}") } } - fun getOwner(destNum: Int) = request( - destNum, - { service, packetId, dest -> service.getRemoteOwner(packetId, dest) }, - "Request getOwner error" - ) - - private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) = request( - destNum, - { service, packetId, dest -> - service.setRemoteChannel(packetId, dest, channel.toByteArray()) - }, - "Request setRemoteChannel error" - ) - - fun getChannel(destNum: Int, index: Int) = request( - destNum, - { service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) }, - "Request getChannel error" - ) - - fun setRemoteConfig(destNum: Int, config: Config) = request( - destNum, - { service, packetId, dest -> - _radioConfigState.update { it.copy(radioConfig = config) } - service.setRemoteConfig(packetId, dest, config.toByteArray()) - }, - "Request setConfig error", - ) - - fun getConfig(destNum: Int, configType: Int) = request( - destNum, - { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) }, - "Request getConfig error", - ) - - fun setModuleConfig(destNum: Int, config: ModuleConfig) = request( - destNum, - { service, packetId, dest -> - _radioConfigState.update { it.copy(moduleConfig = config) } - service.setModuleConfig(packetId, dest, config.toByteArray()) - }, - "Request setConfig error", - ) - - fun getModuleConfig(destNum: Int, configType: Int) = request( - destNum, - { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) }, - "Request getModuleConfig error", - ) - - fun setRingtone(destNum: Int, ringtone: String) { - _radioConfigState.update { it.copy(ringtone = ringtone) } - 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) { - _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - 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 requestShutdown(destNum: Int) = request( - destNum, - { service, packetId, dest -> service.requestShutdown(packetId, dest) }, - "Request shutdown error" - ) - - fun requestReboot(destNum: Int) = request( - destNum, - { service, packetId, dest -> service.requestReboot(packetId, dest) }, - "Request reboot error" - ) - - fun requestFactoryReset(destNum: Int) = request( - destNum, - { service, packetId, dest -> service.requestFactoryReset(packetId, dest) }, - "Request factory reset error" - ) - - fun requestNodedbReset(destNum: Int) = request( - destNum, - { service, packetId, dest -> service.requestNodedbReset(packetId, dest) }, - "Request NodeDB reset error" - ) - fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { try { meshService?.requestPosition(destNum, position) @@ -506,47 +392,8 @@ class UIViewModel @Inject constructor( meshService?.setConfig(config.toByteArray()) } - fun setModuleConfig(config: ModuleConfig) { - setModuleConfig(myNodeNum ?: return, config) - } - - /** - * Updates channels to match the [new] list. Only channels with changes are updated. - * - * @param destNum Destination node number. - * @param old The current [ChannelSettings] list. - * @param new The updated [ChannelSettings] list. - */ - fun updateChannels( - destNum: Int, - old: List, - new: List, - ) { - buildList { - for (i in 0..maxOf(old.lastIndex, new.lastIndex)) { - if (old.getOrNull(i) != new.getOrNull(i)) add(channel { - role = when (i) { - 0 -> ChannelProtos.Channel.Role.PRIMARY - in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY - else -> ChannelProtos.Channel.Role.DISABLED - } - index = i - settings = new.getOrNull(i) ?: channelSettings { } - }) - } - }.forEach { setRemoteChannel(destNum, it) } - - if (destNum == myNodeNum) viewModelScope.launch { - radioConfigRepository.replaceAllSettings(new) - } - _radioConfigState.update { it.copy(channelList = new) } - } - - private fun updateChannels( - old: List, - new: List - ) { - updateChannels(myNodeNum ?: return, old, new) + fun setChannel(channel: ChannelProtos.Channel) { + meshService?.setChannel(channel.toByteArray()) } /** @@ -555,10 +402,15 @@ class UIViewModel @Inject constructor( private var _channelSet: AppOnlyProtos.ChannelSet get() = channels.value.protobuf set(value) { - updateChannels(channelSet.settingsList, value.settingsList) - - val newConfig = config { lora = value.loraConfig } - if (config.lora != newConfig.lora) setConfig(newConfig) + val new = value.settingsList + val old = channelSet.settingsList + viewModelScope.launch { + getChannelList(new, old).forEach(::setChannel) + radioConfigRepository.replaceAllSettings(new) + + val newConfig = config { lora = value.loraConfig } + if (config.lora != newConfig.lora) setConfig(newConfig) + } } val channelSet get() = _channelSet @@ -577,19 +429,15 @@ class UIViewModel @Inject constructor( } } - fun setOwner(user: User) { - setRemoteOwner(myNodeNum ?: return, user) + fun setOwner(user: MeshUser) { + try { + // Note: we use ?. here because we might be running in the emulator + meshService?.setOwner(user) + } catch (ex: RemoteException) { + errormsg("Can't set username on device, is device offline? ${ex.message}") + } } - fun setRemoteOwner(destNum: Int, user: User) = request( - destNum, - { service, packetId, _ -> - _radioConfigState.update { it.copy(userConfig = user) } - service.setRemoteOwner(packetId, user.toByteArray()) - }, - "Request setOwner error", - ) - val adminChannelIndex: Int /** matches [MeshService.adminChannelIndex] **/ get() = channelSet.settingsList.indexOfFirst { it.name.equals("admin", ignoreCase = true) } .coerceAtLeast(0) @@ -705,85 +553,6 @@ class UIViewModel @Inject constructor( } } - private val _deviceProfile = MutableStateFlow(null) - val deviceProfile: StateFlow = _deviceProfile - - fun setDeviceProfile(deviceProfile: DeviceProfile?) { - _deviceProfile.value = deviceProfile - } - - fun importProfile(uri: Uri) = viewModelScope.launch(Dispatchers.IO) { - try { - app.contentResolver.openInputStream(uri).use { inputStream -> - val bytes = inputStream?.readBytes() - val protobuf = DeviceProfile.parseFrom(bytes) - _deviceProfile.value = protobuf - } - } catch (ex: Exception) { - val error = "${ex.javaClass.simpleName}: ${ex.message}" - errormsg("Import DeviceProfile error: ${ex.message}") - setResponseStateError(error) - } - } - - fun exportProfile(uri: Uri) = viewModelScope.launch { - val profile = deviceProfile.value ?: return@launch - writeToUri(uri, profile) - _deviceProfile.value = null - } - - private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) { - try { - app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> - FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream -> - message.writeTo(outputStream) - } - } - setResponseStateSuccess() - } catch (ex: Exception) { - val error = "${ex.javaClass.simpleName}: ${ex.message}" - errormsg("Can't write file error: ${ex.message}") - setResponseStateError(error) - } - } - - fun installProfile(protobuf: DeviceProfile) = with(protobuf) { - _deviceProfile.value = null - // meshService?.beginEditSettings() - if (hasLongName() || hasShortName()) ourNodeInfo.value?.user?.let { - val user = it.copy( - longName = if (hasLongName()) longName else it.longName, - shortName = if (hasShortName()) shortName else it.shortName - ) - setOwner(user.toProto()) - } - if (hasChannelUrl()) { - setChannels(ChannelSet(Uri.parse(channelUrl))) - } - if (hasConfig()) { - setConfig(config { device = config.device }) - setConfig(config { position = config.position }) - setConfig(config { power = config.power }) - setConfig(config { network = config.network }) - setConfig(config { display = config.display }) - setConfig(config { lora = config.lora }) - setConfig(config { bluetooth = config.bluetooth }) - } - if (hasModuleConfig()) moduleConfig.let { - setModuleConfig(moduleConfig { mqtt = it.mqtt }) - setModuleConfig(moduleConfig { serial = it.serial }) - setModuleConfig(moduleConfig { externalNotification = it.externalNotification }) - setModuleConfig(moduleConfig { storeForward = it.storeForward }) - setModuleConfig(moduleConfig { rangeTest = it.rangeTest }) - setModuleConfig(moduleConfig { telemetry = it.telemetry }) - setModuleConfig(moduleConfig { cannedMessage = it.cannedMessage }) - setModuleConfig(moduleConfig { audio = it.audio }) - setModuleConfig(moduleConfig { remoteHardware = it.remoteHardware }) - } - setResponseStateSuccess() - // meshService?.commitEditSettings() - } - fun addQuickChatAction(name: String, value: String, mode: QuickChatAction.Mode) { viewModelScope.launch(Dispatchers.Main) { val action = QuickChatAction(0, name, value, mode, _quickChatActions.value.size) @@ -823,55 +592,6 @@ class UIViewModel @Inject constructor( } } - fun clearPacketResponse() { - requestIds.value = hashMapOf() - _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } - } - - fun setResponseStateLoading(route: String) { - _radioConfigState.value = RadioConfigState( - route = route, - responseState = ResponseState.Loading(), - ) - // channel editor is synchronous, so we don't use requestIds as total - if (route == ConfigRoute.CHANNELS.name) setResponseStateTotal(maxChannels + 1) - } - - private fun setResponseStateTotal(total: Int) { - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - state.copy(responseState = state.responseState.copy(total = total)) - } else { - state // Return the unchanged state for other response states - } - } - } - - private fun setResponseStateSuccess() { - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - state.copy(responseState = ResponseState.Success(true)) - } else { - state // Return the unchanged state for other response states - } - } - } - - private fun setResponseStateError(error: String) { - _radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) } - } - - private fun incrementCompleted() { - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - val increment = state.responseState.completed + 1 - state.copy(responseState = state.responseState.copy(completed = increment)) - } else { - state // Return the unchanged state for other response states - } - } - } - private val _tracerouteResponse = MutableLiveData(null) val tracerouteResponse: LiveData get() = _tracerouteResponse @@ -895,86 +615,5 @@ class UIViewModel @Inject constructor( append(nodeName(packet.from)) } } - val destNum = destNode.value?.num ?: return - val debugMsg = "requestId: ${data.requestId.toUInt()} to: ${destNum.toUInt()} received %s from: ${packet.from.toUInt()}" - - if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) { - val parsed = MeshProtos.Routing.parseFrom(data.payload) - debug(debugMsg.format(parsed.errorReason.name)) - if (parsed.errorReason != MeshProtos.Routing.Error.NONE) { - setResponseStateError(parsed.errorReason.name) - } else if (packet.from == destNum) { - if (requestIds.value.filterValues { !it }.isEmpty()) setResponseStateSuccess() - else incrementCompleted() - } - } - if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) { - val parsed = AdminProtos.AdminMessage.parseFrom(data.payload) - debug(debugMsg.format(parsed.payloadVariantCase.name)) - if (destNum != packet.from) { - setResponseStateError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.") - return - } - // check if destination is channel editor - val goChannels = radioConfigState.value.route == ConfigRoute.CHANNELS.name - when (parsed.payloadVariantCase) { - AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { - val response = parsed.getChannelResponse - // Stop once we get to the first disabled entry - if (response.role != ChannelProtos.Channel.Role.DISABLED) { - _radioConfigState.update { state -> - state.copy(channelList = state.channelList.toMutableList().apply { - add(response.index, response.settings) - }) - } - incrementCompleted() - if (response.index + 1 < maxChannels && goChannels) { - // Not done yet, request next channel - getChannel(destNum, response.index + 1) - } - } else { - // Received last channel, update total and start channel editor - setResponseStateTotal(response.index + 1) - } - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> { - _radioConfigState.update { it.copy(userConfig = parsed.getOwnerResponse) } - incrementCompleted() - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { - val response = parsed.getConfigResponse - if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET - setResponseStateError(response.payloadVariantCase.name) - } - _radioConfigState.update { it.copy(radioConfig = response) } - incrementCompleted() - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> { - val response = parsed.getModuleConfigResponse - if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET - setResponseStateError(response.payloadVariantCase.name) - } - _radioConfigState.update { it.copy(moduleConfig = response) } - incrementCompleted() - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> { - _radioConfigState.update { - it.copy(cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse) - } - incrementCompleted() - } - - AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> { - _radioConfigState.update { it.copy(ringtone = parsed.getRingtoneResponse) } - incrementCompleted() - } - - else -> TODO() - } - } } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index 99dc94328..f43b466cc 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh.repository.datastore import com.geeksville.mesh.AppOnlyProtos.ChannelSet import com.geeksville.mesh.ChannelProtos.Channel import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile import com.geeksville.mesh.ConfigProtos.Config import com.geeksville.mesh.IMeshService import com.geeksville.mesh.LocalOnlyProtos.LocalConfig @@ -12,9 +13,11 @@ import com.geeksville.mesh.MyNodeInfo import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.database.dao.MyNodeInfoDao import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.deviceProfile import com.geeksville.mesh.service.ServiceRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.withContext import javax.inject.Inject @@ -139,4 +142,25 @@ class RadioConfigRepository @Inject constructor( suspend fun setLocalModuleConfig(config: ModuleConfig) { moduleConfigRepository.setLocalModuleConfig(config) } -} \ No newline at end of file + + /** + * Flow representing the combined [DeviceProfile] protobuf. + */ + val deviceProfileFlow: Flow = combine( + myNodeInfoFlow(), + nodeInfoFlow(), + channelSetFlow, + localConfigFlow, + moduleConfigFlow, + ) { myInfo, nodes, channels, localConfig, localModuleConfig -> + deviceProfile { + nodes.firstOrNull { it.num == myInfo?.myNodeNum }?.user?.let { + longName = it.longName + shortName = it.shortName + } + channelUrl = com.geeksville.mesh.model.ChannelSet(channels).getChannelUrl().toString() + config = localConfig + moduleConfig = localModuleConfig + } + } +} 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 990f6f1a5..2297f7e54 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt @@ -36,7 +36,6 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.twotone.KeyboardArrowRight import androidx.compose.runtime.Composable 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 @@ -50,8 +49,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels +import androidx.hilt.navigation.compose.hiltViewModel 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 @@ -60,11 +59,11 @@ import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging import com.geeksville.mesh.config -import com.geeksville.mesh.deviceProfile import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.RadioConfigViewModel import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.moduleConfig -import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.service.MeshService.ConnectionState import com.geeksville.mesh.ui.components.PreferenceCategory import com.geeksville.mesh.ui.components.config.AmbientLightingConfigItemList import com.geeksville.mesh.ui.components.config.AudioConfigItemList @@ -137,9 +136,8 @@ class DeviceSettingsFragment : ScreenFragment("Radio Configuration"), Logging { } ) { innerPadding -> RadioConfigNavHost( - node, - model, - navController, + node = node, + navController = navController, modifier = Modifier.padding(innerPadding), ) } @@ -222,20 +220,20 @@ private fun MeshAppBar( @Composable fun RadioConfigNavHost( node: NodeInfo?, - viewModel: UIViewModel = viewModel(), + viewModel: RadioConfigViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), modifier: Modifier, ) { - val connectionState by viewModel.connectionState.observeAsState() - val connected = connectionState == MeshService.ConnectionState.CONNECTED && node != null + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val connected = connectionState == ConnectionState.CONNECTED && node != null + val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() // FIXME val destNum = node?.num ?: 0 - val isLocal = destNum == viewModel.myNodeNum + val isLocal = destNum == myNodeInfo?.myNodeNum val radioConfigState by viewModel.radioConfigState.collectAsStateWithLifecycle() var location by remember(node) { mutableStateOf(node?.position) } // FIXME - val deviceProfile by viewModel.deviceProfile.collectAsStateWithLifecycle() val isWaiting = radioConfigState.responseState !is ResponseState.Empty var showEditDeviceProfileDialog by remember { mutableStateOf(false) } @@ -256,19 +254,10 @@ fun RadioConfigNavHost( } } + val deviceProfile = viewModel.deviceProfile if (showEditDeviceProfileDialog) EditDeviceProfileDialog( title = if (deviceProfile != null) "Import configuration" else "Export configuration", - deviceProfile = deviceProfile ?: with(viewModel) { - deviceProfile { - ourNodeInfo.value?.user?.let { - longName = it.longName - shortName = it.shortName - } - channelUrl = channels.value.getChannelUrl().toString() - config = localConfig.value - this.moduleConfig = module - } - }, + deviceProfile = deviceProfile ?: viewModel.currentDeviceProfile, onAddClick = { showEditDeviceProfileDialog = false if (deviceProfile != null) { @@ -389,7 +378,7 @@ fun RadioConfigNavHost( enabled = connected, maxChannels = viewModel.maxChannels, onPositiveClicked = { channelListInput -> - viewModel.updateChannels(destNum, radioConfigState.channelList, channelListInput) + viewModel.updateChannels(destNum, channelListInput, radioConfigState.channelList) }, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt index 62e8cd38d..3904a0de3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt @@ -360,7 +360,7 @@ class SettingsFragment : ScreenFragment("Settings"), Logging { val n = binding.usernameEditText.text.toString().trim() model.ourNodeInfo.value?.user?.let { val user = it.copy(longName = n, shortName = getInitials(n)) - if (n.isNotEmpty()) model.setOwner(user.toProto()) + if (n.isNotEmpty()) model.setOwner(user) } requireActivity().hideKeyboard() }