refactor(settings): improve destination node handling in RadioConfigViewModel (#4790)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-13 21:55:46 -05:00
committed by GitHub
parent 2bfd225b68
commit 06f002a198
4 changed files with 79 additions and 55 deletions

View File

@@ -72,12 +72,27 @@ import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigSc
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@PublishedApi
@Composable
internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): AndroidRadioConfigViewModel {
val viewModel = koinViewModel<AndroidRadioConfigViewModel>()
LaunchedEffect(backStack) {
val destNum =
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum }
?: backStack
.lastOrNull { it is SettingsRoutes.SettingsGraph }
?.let { (it as SettingsRoutes.SettingsGraph).destNum }
viewModel.initDestNum(destNum)
}
return viewModel
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.SettingsGraph> {
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(),
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
backStack.add(it)
@@ -87,7 +102,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.Settings> {
SettingsScreen(
settingsViewModel = koinViewModel<AndroidSettingsViewModel>(),
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
backStack.add(it)
@@ -96,7 +111,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.DeviceConfiguration> {
DeviceConfigurationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
@@ -106,7 +121,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
val settingsViewModel: AndroidSettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
@@ -114,10 +129,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
entry<SettingsRoutes.Administration> {
AdministrationScreen(
viewModel = koinViewModel<AndroidRadioConfigViewModel>(),
onBack = { backStack.removeLastOrNull() },
)
AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() })
}
entry<SettingsRoutes.CleanNodeDb> {
@@ -126,7 +138,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
ConfigRoute.entries.forEach { routeInfo ->
configComposable(routeInfo.route::class) { viewModel ->
configComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
@@ -144,7 +156,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
ModuleRoute.entries.forEach { routeInfo ->
configComposable(routeInfo.route::class) { viewModel ->
configComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
@@ -196,13 +208,15 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
fun <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
content: @Composable (AndroidRadioConfigViewModel) -> Unit,
) {
addEntryProvider(route) { content(koinViewModel<AndroidRadioConfigViewModel>()) }
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}
inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable(
backStack: NavBackStack<NavKey>,
noinline content: @Composable (AndroidRadioConfigViewModel) -> Unit,
) {
entry<R> { content(koinViewModel<AndroidRadioConfigViewModel>()) }
entry<R> { content(getRadioConfigViewModel(backStack)) }
}

View File

@@ -52,32 +52,26 @@ compose.desktop {
}
nativeDistributions {
targetFormats(
TargetFormat.Dmg,
TargetFormat.Exe,
TargetFormat.Msi,
TargetFormat.Deb,
TargetFormat.Rpm,
)
targetFormats(TargetFormat.Dmg, TargetFormat.Exe, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
packageName = "Meshtastic"
// Ensure critical JVM modules are included in the custom JRE bundled with the app.
// jdeps might miss some of these if they are loaded via reflection or JNI.
modules(
"java.net.http", // Ktor Java client
"jdk.crypto.ec", // Required for SSL/TLS HTTPS requests
"jdk.unsupported", // sun.misc.Unsafe used by Coroutines & Okio
"java.sql", // Sometimes required by SQLite JNI
"java.naming" // Required by Ktor for DNS resolution
"java.net.http", // Ktor Java client
"jdk.crypto.ec", // Required for SSL/TLS HTTPS requests
"jdk.unsupported", // sun.misc.Unsafe used by Coroutines & Okio
"java.sql", // Sometimes required by SQLite JNI
"java.naming", // Required by Ktor for DNS resolution
)
// Default JVM arguments for the packaged application
// Increase max heap size to prevent OOM issues on complex maps/data
jvmArgs("-Xmx2G")
// App Icon & OS Specific Configurations
macOS {
iconFile.set(project.file("src/main/resources/icon.icns"))
macOS {
iconFile.set(project.file("src/main/resources/icon.icns"))
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
// You can inject these from CI environment variables.
// bundleID = "org.meshtastic.desktop"
@@ -86,22 +80,23 @@ compose.desktop {
// appleID = System.getenv("APPLE_ID")
// appStorePassword = System.getenv("APPLE_APP_SPECIFIC_PASSWORD")
}
windows {
iconFile.set(project.file("src/main/resources/icon.ico"))
windows {
iconFile.set(project.file("src/main/resources/icon.ico"))
menuGroup = "Meshtastic"
// TODO: Must generate and set a consistent UUID for Windows upgrades.
// TODO: Must generate and set a consistent UUID for Windows upgrades.
// upgradeUuid = "YOUR-UPGRADE-UUID-HERE"
}
linux {
iconFile.set(project.file("src/main/resources/icon.png"))
linux {
iconFile.set(project.file("src/main/resources/icon.png"))
menuGroup = "Network"
}
// Read version from project properties (passed by CI) or default to 1.0.0
// Native installers require strict numeric semantic versions (X.Y.Z) without suffixes
val rawVersion = project.findProperty("android.injected.version.name")?.toString()
?: System.getenv("VERSION_NAME")
?: "1.0.0"
val rawVersion =
project.findProperty("android.injected.version.name")?.toString()
?: System.getenv("VERSION_NAME")
?: "1.0.0"
val sanitizedVersion = Regex("^\\d+\\.\\d+\\.\\d+").find(rawVersion)?.value ?: "1.0.0"
packageVersion = sanitizedVersion
@@ -207,4 +202,4 @@ aboutLibraries {
duplicationMode = DuplicateMode.MERGE
duplicationRule = DuplicateRule.SIMPLE
}
}
}

View File

@@ -67,6 +67,20 @@ import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigSc
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@Composable
private fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<RadioConfigViewModel>()
LaunchedEffect(backStack) {
val destNum =
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum }
?: backStack
.lastOrNull { it is SettingsRoutes.SettingsGraph }
?.let { (it as SettingsRoutes.SettingsGraph).destNum }
viewModel.initDestNum(destNum)
}
return viewModel
}
/**
* Registers real settings feature composables into the desktop navigation graph.
*
@@ -79,7 +93,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
// Top-level settings — desktop-specific screen (Android version uses Activity, permissions, etc.)
entry<SettingsRoutes.SettingsGraph> {
DesktopSettingsScreen(
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
radioConfigViewModel = getRadioConfigViewModel(backStack),
settingsViewModel = koinViewModel<SettingsViewModel>(),
onNavigate = { route -> backStack.add(route) },
)
@@ -87,7 +101,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
entry<SettingsRoutes.Settings> {
DesktopSettingsScreen(
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
radioConfigViewModel = getRadioConfigViewModel(backStack),
settingsViewModel = koinViewModel<SettingsViewModel>(),
onNavigate = { route -> backStack.add(route) },
)
@@ -96,7 +110,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
// Device configuration — shared commonMain composable
entry<SettingsRoutes.DeviceConfiguration> {
DeviceConfigurationScreen(
viewModel = koinViewModel<RadioConfigViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
@@ -107,7 +121,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
val settingsViewModel: SettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = koinViewModel<RadioConfigViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
@@ -116,10 +130,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
// Administration — shared commonMain composable
entry<SettingsRoutes.Administration> {
AdministrationScreen(
viewModel = koinViewModel<RadioConfigViewModel>(),
onBack = { backStack.removeLastOrNull() },
)
AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() })
}
// Clean node database — shared commonMain composable
@@ -139,7 +150,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
// Config routes — all from commonMain composables
ConfigRoute.entries.forEach { routeInfo ->
desktopConfigComposable(routeInfo.route::class) { viewModel ->
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
@@ -160,7 +171,7 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
// Module routes — all from commonMain composables
ModuleRoute.entries.forEach { routeInfo ->
desktopConfigComposable(routeInfo.route::class) { viewModel ->
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
@@ -210,7 +221,8 @@ fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavK
/** Helper to register a config/module route entry with a [RadioConfigViewModel] scoped to that entry. */
fun <R : Route> EntryProviderScope<NavKey>.desktopConfigComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
content: @Composable (RadioConfigViewModel) -> Unit,
) {
addEntryProvider(route) { content(koinViewModel<RadioConfigViewModel>()) }
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}

View File

@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -127,7 +126,13 @@ open class RadioConfigViewModel(
toggleHomoglyphEncodingUseCase()
}
private val destNum = savedStateHandle.get<Int>("destNum")
private val destNumFlow = MutableStateFlow(savedStateHandle.get<Int>("destNum"))
fun initDestNum(id: Int?) {
if (id != null && destNumFlow.value != id) {
destNumFlow.value = id
}
}
private val _destNode = MutableStateFlow<Node?>(null)
val destNode: StateFlow<Node?>
@@ -148,8 +153,7 @@ open class RadioConfigViewModel(
open suspend fun getCurrentLocation(): Any? = null
init {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] ?: nodes.values.firstOrNull() }
combine(destNumFlow, nodeRepository.nodeDBbyNum) { id, nodes -> nodes[id] ?: nodes.values.firstOrNull() }
.distinctUntilChanged()
.onEach {
_destNode.value = it
@@ -182,10 +186,9 @@ open class RadioConfigViewModel(
}
.launchIn(viewModelScope)
nodeRepository.myNodeInfo
.onEach { ni ->
_radioConfigState.update { it.copy(isLocal = (destNum == null) || (destNum == ni?.myNodeNum)) }
}
combine(nodeRepository.myNodeInfo, destNumFlow) { ni, id ->
_radioConfigState.update { it.copy(isLocal = (id == null) || (id == ni?.myNodeNum)) }
}
.launchIn(viewModelScope)
Logger.d { "RadioConfigViewModel created" }