feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)

This commit is contained in:
James Rich 2026-03-21 18:19:13 -05:00 committed by GitHub
parent f04924ded5
commit d136b162a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
170 changed files with 2208 additions and 2432 deletions

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import org.meshtastic.core.navigation.SettingsRoutes
actual fun getAboutLibrariesJson(): String =
SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.DeviceConfigScreen as AndroidDeviceConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen as AndroidExternalNotificationConfigScreen
import org.meshtastic.feature.settings.radio.component.PositionConfigScreen as AndroidPositionConfigScreen
import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen as AndroidSecurityConfigScreen
@Composable
actual fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
) {
SettingsScreen(
settingsViewModel = settingsViewModel,
viewModel = radioConfigViewModel,
onClickNodeChip = onClickNodeChip,
onNavigate = onNavigate,
)
}
@Composable
actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidDeviceConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidPositionConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidSecurityConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack)
}

View file

@ -34,6 +34,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.MeshLog
@ -277,7 +278,7 @@ class DebugViewModel(
}
}
suspend fun loadLogsForExport(): ImmutableList<UiMeshLog> = withContext(Dispatchers.IO) {
suspend fun loadLogsForExport(): ImmutableList<UiMeshLog> = withContext(ioDispatcher) {
val unbounded = meshLogRepository.getAllLogsUnbounded().first()
val logs = if (unbounded.isEmpty()) meshLogRepository.getAllLogs().first() else unbounded
toUiState(logs)
@ -405,7 +406,7 @@ class DebugViewModel(
)
}
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() }
fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() }
@Immutable
data class UiMeshLog(

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
expect fun getAboutLibrariesJson(): String

View file

@ -14,8 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("Wrapping", "SpacingAroundColon")
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
@ -33,7 +31,6 @@ import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.debugging.DebugViewModel
@ -48,19 +45,15 @@ import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen
import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen
import org.meshtastic.feature.settings.radio.component.DeviceConfigScreen
import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
import org.meshtastic.feature.settings.radio.component.PositionConfigScreen
import org.meshtastic.feature.settings.radio.component.PowerConfigScreen
import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen
import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen
import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
@ -70,9 +63,8 @@ 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>): RadioConfigViewModel {
fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<RadioConfigViewModel>()
LaunchedEffect(backStack) {
val destNum =
@ -88,23 +80,21 @@ internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConf
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.SettingsGraph> {
SettingsScreen(
settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
SettingsMainScreen(
settingsViewModel = koinViewModel(),
radioConfigViewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
backStack.add(it)
}
onNavigate = { backStack.add(it) },
)
}
entry<SettingsRoutes.Settings> {
SettingsScreen(
settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
SettingsMainScreen(
settingsViewModel = koinViewModel(),
radioConfigViewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
backStack.add(it)
}
onNavigate = { backStack.add(it) },
)
}
entry<SettingsRoutes.DeviceConfiguration> {
@ -192,10 +182,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
entry<SettingsRoutes.About> {
AboutScreen(
onNavigateUp = { backStack.removeLastOrNull() },
jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" },
)
AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() })
}
entry<SettingsRoutes.FilterSettings> {
@ -204,6 +191,24 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
}
/** Expect declaration for the platform-specific settings main screen. */
@Composable
expect fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
)
/** Expect declarations for platform-specific config screens. */
@Composable expect fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@Composable expect fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@Composable expect fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@Composable expect fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
fun <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
@ -211,10 +216,3 @@ fun <R : Route> EntryProviderScope<NavKey>.configComposable(
) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}
inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable(
backStack: NavBackStack<NavKey>,
noinline content: @Composable (RadioConfigViewModel) -> Unit,
) {
entry<R> { content(getRadioConfigViewModel(backStack)) }
}

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.meshtastic.core.common.util.formatString
import org.meshtastic.feature.settings.radio.ResponseState
private const val LOADING_OVERLAY_ALPHA = 0.8f
@ -72,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) {
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Text(
text = "%.0f%%".format(progress * PERCENTAGE_FACTOR),
text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)

View file

@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -31,19 +30,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.extractWifiCredentials
import org.meshtastic.core.model.util.handleMeshtasticUri
import org.meshtastic.core.model.util.toCommonUri
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.cancel
@ -56,7 +51,6 @@ import org.meshtastic.core.resources.ethernet_config
import org.meshtastic.core.resources.ethernet_enabled
import org.meshtastic.core.resources.ethernet_ip
import org.meshtastic.core.resources.gateway
import org.meshtastic.core.resources.ip
import org.meshtastic.core.resources.ipv4_mode
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.nfc_disabled
@ -81,7 +75,9 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.openNfcSettings
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
@ -89,12 +85,18 @@ import org.meshtastic.proto.Config
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
@Suppress("detekt:MagicNumber")
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
"${(ipAddress shr 8) and 0xFF}." +
"${(ipAddress shr 16) and 0xFF}." +
"${(ipAddress shr 24) and 0xFF}"
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onOpenNfcSettings: () -> Unit = {}) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
val formState = rememberConfigState(initialValue = networkConfig)
val context = LocalContext.current
var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) }
if (showScanErrorDialog) {
@ -109,7 +111,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
message = stringResource(Res.string.nfc_disabled),
confirmText = stringResource(Res.string.open_settings),
onConfirm = {
context.openNfcSettings()
onOpenNfcSettings()
showNfcDisabledDialog = false
},
dismissText = stringResource(Res.string.cancel),
@ -120,7 +122,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
if (contents != null) {
val handled =
handleMeshtasticUri(
uri = contents.toUri().toCommonUri(),
uri = CommonUri.parse(contents),
onChannel = {}, // No-op, not supported in network config
onContact = {}, // No-op, not supported in network config
)
@ -136,8 +138,10 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
}
}
val barcodeScanner = rememberBarcodeScanner(onResult = onResult)
NfcScannerEffect(onResult = onResult, onNfcDisabled = { showNfcDisabledDialog = true })
val barcodeScanner = LocalBarcodeScannerProvider.current(onResult)
if (LocalNfcScannerSupported.current) {
LocalNfcScannerProvider.current(onResult) { showNfcDisabledDialog = true }
}
val focusManager = LocalFocusManager.current
@ -164,7 +168,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
if (wifiStatus.is_connected) {
ListItem(
text = stringResource(Res.string.wifi_ip),
supportingText = formatIpAddress(wifiStatus.ip_address),
supportingText = formatIpAddress(wifiStatus.ip_address ?: 0),
trailingIcon = null,
)
}
@ -173,7 +177,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
if (ethernetStatus.is_connected) {
ListItem(
text = stringResource(Res.string.ethernet_ip),
supportingText = formatIpAddress(ethernetStatus.ip_address),
supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0),
trailingIcon = null,
)
}
@ -182,17 +186,17 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
}
}
}
if (state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.wifi_config)) {
SwitchPreference(
title = stringResource(Res.string.wifi_enabled),
summary = stringResource(Res.string.config_network_wifi_enabled_summary),
checked = formState.value.wifi_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
item {
TitledCard(title = stringResource(Res.string.wifi_config)) {
SwitchPreference(
title = stringResource(Res.string.wifi_enabled),
summary = stringResource(Res.string.config_network_wifi_enabled_summary),
checked = formState.value.wifi_enabled,
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
enabled = state.connected,
)
if (formState.value.wifi_enabled) {
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ssid),
@ -232,31 +236,12 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.ethernet_enabled),
summary = stringResource(Res.string.config_network_eth_enabled_summary),
checked = formState.value.eth_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.network)) {
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked = formState.value.enabled_protocols == 1,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0)
},
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.advanced)) {
EditTextPreference(
@ -264,7 +249,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
value = formState.value.ntp_server,
maxSize = 32, // ntp_server max_size:33
enabled = state.connected,
isError = formState.value.ntp_server.isEmpty(),
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
@ -283,57 +268,63 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked = formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
onCheckedChange = {
formState.value =
formState.value.copy(
address_mode =
if (it) {
Config.NetworkConfig.AddressMode.STATIC
} else {
Config.NetworkConfig.AddressMode.DHCP
},
)
},
enabled = state.connected,
items = Config.NetworkConfig.AddressMode.entries.map { it to it.name },
selectedItem = formState.value.address_mode,
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
)
HorizontalDivider()
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
EditIPv4Preference(
title = stringResource(Res.string.ip),
value = ipv4.ip,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.gateway),
value = ipv4.gateway,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.subnet),
value = ipv4.subnet,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = "DNS",
value = ipv4.dns,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) },
)
if (formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC) {
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
enabled = state.connected,
selectedItem = formState.value.address_mode,
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
itemLabel = { it.name },
)
HorizontalDivider()
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
EditIPv4Preference(
title = stringResource(Res.string.wifi_ip),
value = ipv4.ip,
enabled = state.connected,
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.gateway),
value = ipv4.gateway,
enabled = state.connected,
onValueChanged = {
formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it))
},
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.subnet),
value = ipv4.subnet,
enabled = state.connected,
onValueChanged = {
formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it))
},
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
}
}
}
}
}
@Suppress("detekt:MagicNumber")
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
"${(ipAddress shr 8) and 0xFF}." +
"${(ipAddress shr 16) and 0xFF}." +
"${(ipAddress shr 24) and 0xFF}"

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.close
@ -110,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit)
val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress")
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "%.0f%%".format(progress * 100f),
text = formatString("%.0f%%", progress * 100f),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.secondary,
)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
@Composable
actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (fileName: String) -> Unit =
{ _ ->
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
actual fun getAboutLibrariesJson(): String {
return "" // TODO: Implement reading aboutlibraries.json on iOS
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
actual fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
) {
// TODO: Implement iOS settings main screen
}
@Composable
actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS device config screen
}
@Composable
actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS position config screen
}
@Composable
actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS security config screen
}
@Composable
actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS external notification config screen
}

View file

@ -0,0 +1,461 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.accept
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.button_gpio
import org.meshtastic.core.resources.buzzer_gpio
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary
import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary
import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary
import org.meshtastic.core.resources.config_device_tzdef_summary
import org.meshtastic.core.resources.config_device_use_phone_tz
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.double_tap_as_button_press
import org.meshtastic.core.resources.gpio
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.led_heartbeat
import org.meshtastic.core.resources.nodeinfo_broadcast_interval
import org.meshtastic.core.resources.options
import org.meshtastic.core.resources.rebroadcast_mode
import org.meshtastic.core.resources.rebroadcast_mode_all_desc
import org.meshtastic.core.resources.rebroadcast_mode_all_skip_decoding_desc
import org.meshtastic.core.resources.rebroadcast_mode_core_portnums_only_desc
import org.meshtastic.core.resources.rebroadcast_mode_known_only_desc
import org.meshtastic.core.resources.rebroadcast_mode_local_only_desc
import org.meshtastic.core.resources.rebroadcast_mode_none_desc
import org.meshtastic.core.resources.role
import org.meshtastic.core.resources.role_client_base_desc
import org.meshtastic.core.resources.role_client_desc
import org.meshtastic.core.resources.role_client_hidden_desc
import org.meshtastic.core.resources.role_client_mute_desc
import org.meshtastic.core.resources.role_lost_and_found_desc
import org.meshtastic.core.resources.role_repeater_desc
import org.meshtastic.core.resources.role_router_client_desc
import org.meshtastic.core.resources.role_router_desc
import org.meshtastic.core.resources.role_router_late_desc
import org.meshtastic.core.resources.role_sensor_desc
import org.meshtastic.core.resources.role_tak_desc
import org.meshtastic.core.resources.role_tak_tracker_desc
import org.meshtastic.core.resources.role_tracker_desc
import org.meshtastic.core.resources.router_role_confirmation_text
import org.meshtastic.core.resources.time_zone
import org.meshtastic.core.resources.triple_click_adhoc_ping
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.role
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.zone.ZoneOffsetTransitionRule
import java.util.Locale
import kotlin.math.abs
private val Config.DeviceConfig.Role.description: StringResource
get() =
when (this) {
Config.DeviceConfig.Role.CLIENT -> Res.string.role_client_desc
Config.DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc
Config.DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc
Config.DeviceConfig.Role.ROUTER -> Res.string.role_router_desc
Config.DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc
Config.DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc
Config.DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc
Config.DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc
Config.DeviceConfig.Role.TAK -> Res.string.role_tak_desc
Config.DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc
Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc
Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc
Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc
else -> Res.string.unrecognized
}
private val Config.DeviceConfig.RebroadcastMode.description: StringResource
get() =
when (this) {
Config.DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc
Config.DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc
Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc
Config.DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc
Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
Res.string.rebroadcast_mode_core_portnums_only_desc
else -> Res.string.unrecognized
}
@Composable
@Suppress("LongMethod")
fun DesktopDeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig()
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role ?: Config.DeviceConfig.Role.CLIENT) }
val infrastructureRoles =
listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
if (selectedRole in infrastructureRoles) {
DesktopRouterRoleConfirmationDialog(
onDismiss = { selectedRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT },
onConfirm = { formState.value = formState.value.copy(role = selectedRole) },
)
} else {
formState.value = formState.value.copy(role = selectedRole)
}
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.device),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = Config(device = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.options)) {
val currentRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT
DropDownPreference(
title = stringResource(Res.string.role),
enabled = state.connected,
selectedItem = currentRole,
onItemSelected = { selectedRole = it },
summary = stringResource(currentRole.description),
itemIcon = { MeshtasticIcons.role(it) },
itemLabel = { it.name },
)
HorizontalDivider()
val currentRebroadcastMode = formState.value.rebroadcast_mode ?: Config.DeviceConfig.RebroadcastMode.ALL
DropDownPreference(
title = stringResource(Res.string.rebroadcast_mode),
enabled = state.connected,
selectedItem = currentRebroadcastMode,
onItemSelected = { formState.value = formState.value.copy(rebroadcast_mode = it) },
summary = stringResource(currentRebroadcastMode.description),
)
HorizontalDivider()
val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.nodeinfo_broadcast_interval),
selectedItem = (formState.value.node_info_broadcast_secs ?: 0).toLong(),
enabled = state.connected,
items = nodeInfoBroadcastIntervals.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy(node_info_broadcast_secs = it.toInt()) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.hardware)) {
SwitchPreference(
title = stringResource(Res.string.double_tap_as_button_press),
summary = stringResource(Res.string.config_device_doubleTapAsButtonPress_summary),
checked = formState.value.double_tap_as_button_press,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(double_tap_as_button_press = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
InsetDivider()
SwitchPreference(
title = stringResource(Res.string.triple_click_adhoc_ping),
summary = stringResource(Res.string.config_device_tripleClickAsAdHocPing_summary),
checked = !formState.value.disable_triple_click,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(disable_triple_click = !it) },
containerColor = CardDefaults.cardColors().containerColor,
)
InsetDivider()
SwitchPreference(
title = stringResource(Res.string.led_heartbeat),
summary = stringResource(Res.string.config_device_ledHeartbeatEnabled_summary),
checked = !formState.value.led_heartbeat_disabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(led_heartbeat_disabled = !it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.time_zone)) {
val systemTzPosixString = remember { ZoneId.systemDefault().toPosixString() }
EditTextPreference(
title = "",
value = formState.value.tzdef ?: "",
summary = stringResource(Res.string.config_device_tzdef_summary),
maxSize = 64, // tzdef max_size:65
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(tzdef = it) },
trailingIcon = {
IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) {
Icon(imageVector = Icons.Rounded.Clear, contentDescription = null)
}
},
)
HorizontalDivider()
TextButton(
modifier = Modifier.height(40.dp).fillMaxWidth(),
enabled = state.connected,
shape = RectangleShape,
onClick = { formState.value = formState.value.copy(tzdef = systemTzPosixString) },
) {
Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(Res.string.config_device_use_phone_tz))
}
}
}
item {
TitledCard(title = stringResource(Res.string.gpio)) {
EditTextPreference(
title = stringResource(Res.string.button_gpio),
value = formState.value.button_gpio ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(button_gpio = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.buzzer_gpio),
value = formState.value.buzzer_gpio ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(buzzer_gpio = it) },
)
}
}
}
}
@Composable
private fun DesktopRouterRoleConfirmationDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
val dialogTitle = stringResource(Res.string.are_you_sure)
val dialogText = stringResource(Res.string.router_role_confirmation_text)
var confirmed by rememberSaveable { mutableStateOf(false) }
AlertDialog(
title = { Text(text = dialogTitle) },
text = {
Column {
Text(text = dialogText)
Row(
modifier = Modifier.fillMaxWidth().clickable(true) { confirmed = !confirmed },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = confirmed, onCheckedChange = { confirmed = it })
Text(stringResource(Res.string.i_know_what_i_m_doing))
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm, enabled = confirmed) { Text(stringResource(Res.string.accept)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
/** Generates a POSIX time zone string from a [ZoneId]. JVM/Desktop version of the Android-only `core:model` utility. */
@Suppress("MagicNumber", "ReturnCount")
private fun ZoneId.toPosixString(): String {
val rules = this.rules
if (rules.isFixedOffset || rules.transitionRules.isEmpty()) {
val now = java.time.Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds }
val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds }
if (springRule == null || fallRule == null) {
val now = java.time.Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
return buildString {
val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule)
val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule)
append(formatAbbreviation(stdAbbrev))
append(formatPosixOffset(springRule.offsetBefore))
append(formatAbbreviation(dstAbbrev))
if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) {
append(formatPosixOffset(springRule.offsetAfter))
}
append(formatTransitionRule(springRule))
append(formatTransitionRule(fallRule))
}
}
private fun ZonedDateTime.timeZoneShortName(): String {
val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH)
val shortName = format(formatter)
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
private fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
private fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val year = java.time.LocalDate.now().year
val transition = rule.createTransition(year)
return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName()
}
@Suppress("MagicNumber")
private fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600
val minutes = remainingSeconds / 60
val seconds = remainingSeconds % 60
return buildString {
if (offsetSeconds < 0 && hours == 0) append("-")
append(hours)
if (minutes != 0 || seconds != 0) {
append(":%02d".format(Locale.ENGLISH, minutes))
if (seconds != 0) {
append(":%02d".format(Locale.ENGLISH, seconds))
}
}
}
}
@Suppress("MagicNumber")
private fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String {
val month = rule.month.value
val dayOfWeek = rule.dayOfWeek.value % 7
val dayIndicator = rule.dayOfMonthIndicator
val occurrence =
when {
dayIndicator < 0 -> 5
dayIndicator > rule.month.length(false) - 7 -> 5
else -> ((dayIndicator - 1) / 7) + 1
}
val wallTime =
when (rule.timeDefinition) {
ZoneOffsetTransitionRule.TimeDefinition.UTC ->
rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong())
ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> {
if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) {
rule.localTime
} else {
rule.localTime.plusSeconds(
(rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(),
)
}
}
else -> rule.localTime
}
return buildString {
append(",M$month.$occurrence.$dayOfWeek")
if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) {
append("/${wallTime.hour}")
if (wallTime.minute != 0 || wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.minute))
if (wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.second))
}
}
}
}
}

View file

@ -0,0 +1,254 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.alert_bell_buzzer
import org.meshtastic.core.resources.alert_bell_led
import org.meshtastic.core.resources.alert_bell_vibra
import org.meshtastic.core.resources.alert_message_buzzer
import org.meshtastic.core.resources.alert_message_led
import org.meshtastic.core.resources.alert_message_vibra
import org.meshtastic.core.resources.external_notification
import org.meshtastic.core.resources.external_notification_config
import org.meshtastic.core.resources.external_notification_enabled
import org.meshtastic.core.resources.nag_timeout_seconds
import org.meshtastic.core.resources.notifications_on_alert_bell_receipt
import org.meshtastic.core.resources.notifications_on_message_receipt
import org.meshtastic.core.resources.output_buzzer_gpio
import org.meshtastic.core.resources.output_duration_milliseconds
import org.meshtastic.core.resources.output_led_active_high
import org.meshtastic.core.resources.output_led_gpio
import org.meshtastic.core.resources.output_vibra_gpio
import org.meshtastic.core.resources.ringtone
import org.meshtastic.core.resources.use_i2s_as_buzzer
import org.meshtastic.core.resources.use_pwm_buzzer
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
private const val MAX_RINGTONE_SIZE = 230
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig()
val ringtone = state.ringtone
val formState = rememberConfigState(initialValue = extNotificationConfig)
var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.external_notification),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
additionalDirtyCheck = { ringtoneInput != ringtone },
onDiscard = { ringtoneInput = ringtone },
onSave = {
if (ringtoneInput != ringtone) {
viewModel.setRingtone(ringtoneInput)
}
if (formState.value != extNotificationConfig) {
val config = ModuleConfig(external_notification = formState.value)
viewModel.setModuleConfig(config)
}
},
) {
item {
TitledCard(title = stringResource(Res.string.external_notification_config)) {
SwitchPreference(
title = stringResource(Res.string.external_notification_enabled),
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.notifications_on_message_receipt)) {
SwitchPreference(
title = stringResource(Res.string.alert_message_led),
checked = formState.value.alert_message ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_message = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_message_buzzer),
checked = formState.value.alert_message_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_message_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_message_vibra),
checked = formState.value.alert_message_vibra ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_message_vibra = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.notifications_on_alert_bell_receipt)) {
SwitchPreference(
title = stringResource(Res.string.alert_bell_led),
checked = formState.value.alert_bell ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_bell = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_bell_buzzer),
checked = formState.value.alert_bell_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_bell_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_bell_vibra),
checked = formState.value.alert_bell_vibra ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_bell_vibra = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.advanced)) {
val gpio = remember { gpioPins }
DropDownPreference(
title = stringResource(Res.string.output_led_gpio),
items = gpio,
selectedItem = (formState.value.output ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output = it.toInt()) },
)
if (formState.value.output ?: 0 != 0) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.output_led_active_high),
checked = formState.value.active ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(active = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.output_buzzer_gpio),
items = gpio,
selectedItem = (formState.value.output_buzzer ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output_buzzer = it.toInt()) },
)
if (formState.value.output_buzzer ?: 0 != 0) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_pwm_buzzer),
checked = formState.value.use_pwm ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(use_pwm = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.output_vibra_gpio),
items = gpio,
selectedItem = (formState.value.output_vibra ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output_vibra = it.toInt()) },
)
HorizontalDivider()
val outputItems = remember { IntervalConfiguration.OUTPUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.output_duration_milliseconds),
items = outputItems.map { it.value to it.toDisplayString() },
selectedItem = (formState.value.output_ms ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output_ms = it.toInt()) },
)
HorizontalDivider()
val nagItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.nag_timeout_seconds),
items = nagItems.map { it.value to it.toDisplayString() },
selectedItem = (formState.value.nag_timeout ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(nag_timeout = it.toInt()) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ringtone),
value = ringtoneInput,
maxSize = MAX_RINGTONE_SIZE,
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { ringtoneInput = it },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_i2s_as_buzzer),
checked = formState.value.use_i2s_as_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(use_i2s_as_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
}

View file

@ -0,0 +1,295 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Position
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced_device_gps
import org.meshtastic.core.resources.altitude
import org.meshtastic.core.resources.broadcast_interval
import org.meshtastic.core.resources.config_position_broadcast_secs_summary
import org.meshtastic.core.resources.config_position_broadcast_smart_minimum_distance_summary
import org.meshtastic.core.resources.config_position_broadcast_smart_minimum_interval_secs_summary
import org.meshtastic.core.resources.config_position_flags_summary
import org.meshtastic.core.resources.config_position_gps_update_interval_summary
import org.meshtastic.core.resources.device_gps
import org.meshtastic.core.resources.fixed_position
import org.meshtastic.core.resources.gps_en_gpio
import org.meshtastic.core.resources.gps_mode
import org.meshtastic.core.resources.gps_receive_gpio
import org.meshtastic.core.resources.gps_transmit_gpio
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.minimum_distance
import org.meshtastic.core.resources.minimum_interval
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.position_flags
import org.meshtastic.core.resources.position_packet
import org.meshtastic.core.resources.smart_position
import org.meshtastic.core.resources.update_interval
import org.meshtastic.core.ui.component.BitwisePreference
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.feature.settings.util.FixedUpdateIntervals
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val node by viewModel.destNode.collectAsStateWithLifecycle()
val currentPosition =
Position(
latitude = node?.latitude ?: 0.0,
longitude = node?.longitude ?: 0.0,
altitude = node?.position?.altitude ?: 0,
time = 1, // ignore time for fixed_position
)
val positionConfig = state.radioConfig.position ?: Config.PositionConfig()
val sanitizedPositionConfig =
remember(positionConfig) {
val positionItems = IntervalConfiguration.POSITION.allowedIntervals
val smartBroadcastItems = IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals
var updated = positionConfig
if (FixedUpdateIntervals.fromValue(updated.position_broadcast_secs.toLong()) == null) {
updated = updated.copy(position_broadcast_secs = positionItems.first().value.toInt())
}
if (FixedUpdateIntervals.fromValue(updated.broadcast_smart_minimum_interval_secs.toLong()) == null) {
updated =
updated.copy(broadcast_smart_minimum_interval_secs = smartBroadcastItems.first().value.toInt())
}
if (FixedUpdateIntervals.fromValue(updated.gps_update_interval.toLong()) == null) {
updated = updated.copy(gps_update_interval = positionItems.first().value.toInt())
}
updated
}
val formState = rememberConfigState(initialValue = sanitizedPositionConfig)
var locationInput by rememberSaveable { mutableStateOf(currentPosition) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.position),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
additionalDirtyCheck = { locationInput != currentPosition },
onDiscard = { locationInput = currentPosition },
onSave = {
if (formState.value.fixed_position) {
if (locationInput != currentPosition) {
viewModel.setFixedPosition(locationInput)
}
} else {
if (positionConfig.fixed_position) {
// fixed position changed from enabled to disabled
viewModel.removeFixedPosition()
}
}
val config = Config(position = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.position_packet)) {
val items = remember { IntervalConfiguration.POSITION_BROADCAST.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.broadcast_interval),
summary = stringResource(Res.string.config_position_broadcast_secs_summary),
enabled = state.connected,
items = items.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue((formState.value.position_broadcast_secs ?: 0).toLong())
?: items.first(),
onItemSelected = {
formState.value = formState.value.copy(position_broadcast_secs = it.value.toInt())
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.smart_position),
checked = formState.value.position_broadcast_smart_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(position_broadcast_smart_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.position_broadcast_smart_enabled ?: false) {
HorizontalDivider()
val smartItems = remember { IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.minimum_interval),
summary =
stringResource(Res.string.config_position_broadcast_smart_minimum_interval_secs_summary),
enabled = state.connected,
items = smartItems.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue(
(formState.value.broadcast_smart_minimum_interval_secs ?: 0).toLong(),
) ?: smartItems.first(),
onItemSelected = {
formState.value =
formState.value.copy(broadcast_smart_minimum_interval_secs = it.value.toInt())
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.minimum_distance),
summary = stringResource(Res.string.config_position_broadcast_smart_minimum_distance_summary),
value = formState.value.broadcast_smart_minimum_distance ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
formState.value = formState.value.copy(broadcast_smart_minimum_distance = it)
},
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.device_gps)) {
SwitchPreference(
title = stringResource(Res.string.fixed_position),
checked = formState.value.fixed_position ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(fixed_position = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.fixed_position ?: false) {
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.latitude),
value = locationInput.latitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { lat: Double ->
if (lat >= -90 && lat <= 90.0) {
locationInput = locationInput.copy(latitude = lat)
}
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.longitude),
value = locationInput.longitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { lon: Double ->
if (lon >= -180 && lon <= 180.0) {
locationInput = locationInput.copy(longitude = lon)
}
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.altitude),
value = locationInput.altitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { alt: Int -> locationInput = locationInput.copy(altitude = alt) },
)
} else {
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_mode),
enabled = state.connected,
items = Config.PositionConfig.GpsMode.entries.map { it to it.name },
selectedItem = formState.value.gps_mode ?: Config.PositionConfig.GpsMode.DISABLED,
onItemSelected = { formState.value = formState.value.copy(gps_mode = it) },
)
HorizontalDivider()
val items = remember { IntervalConfiguration.GPS_UPDATE.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.update_interval),
summary = stringResource(Res.string.config_position_gps_update_interval_summary),
enabled = state.connected,
items = items.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue((formState.value.gps_update_interval ?: 0).toLong())
?: items.first(),
onItemSelected = {
formState.value = formState.value.copy(gps_update_interval = it.value.toInt())
},
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.position_flags)) {
BitwisePreference(
title = stringResource(Res.string.position_flags),
summary = stringResource(Res.string.config_position_flags_summary),
value = formState.value.position_flags ?: 0,
enabled = state.connected,
items =
Config.PositionConfig.PositionFlags.entries
.filter { it != Config.PositionConfig.PositionFlags.UNSET }
.map { it.value to it.name },
onItemSelected = { formState.value = formState.value.copy(position_flags = it) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.advanced_device_gps)) {
val pins = remember { gpioPins }
DropDownPreference(
title = stringResource(Res.string.gps_receive_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.rx_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(rx_gpio = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_transmit_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.tx_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(tx_gpio = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_en_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.gps_en_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(gps_en_gpio = it) },
)
}
}
}
}

View file

@ -0,0 +1,232 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.admin_key
import org.meshtastic.core.resources.admin_keys
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.config_security_admin_key
import org.meshtastic.core.resources.config_security_debug_log_api_enabled
import org.meshtastic.core.resources.config_security_is_managed
import org.meshtastic.core.resources.config_security_private_key
import org.meshtastic.core.resources.config_security_public_key
import org.meshtastic.core.resources.config_security_serial_enabled
import org.meshtastic.core.resources.debug_log_api_enabled
import org.meshtastic.core.resources.direct_message_key
import org.meshtastic.core.resources.legacy_admin_channel
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.managed_mode
import org.meshtastic.core.resources.private_key
import org.meshtastic.core.resources.public_key
import org.meshtastic.core.resources.regenerate_keys_confirmation
import org.meshtastic.core.resources.regenerate_private_key
import org.meshtastic.core.resources.security
import org.meshtastic.core.resources.serial_console
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.NodeActionButton
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.proto.Config
import java.security.SecureRandom
@Composable
@Suppress("LongMethod")
fun DesktopSecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
val formState = rememberConfigState(initialValue = securityConfig)
var publicKey by rememberSaveable { mutableStateOf(formState.value.public_key) }
LaunchedEffect(formState.value.private_key) {
if (formState.value.private_key != securityConfig.private_key) {
publicKey = ByteString.EMPTY
} else if (formState.value.private_key == securityConfig.private_key) {
publicKey = securityConfig.public_key
}
}
var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) }
if (showKeyGenerationDialog) {
DesktopPrivateKeyRegenerateDialog(
onConfirm = {
formState.value = it
showKeyGenerationDialog = false
val config = Config(security = formState.value)
viewModel.setConfig(config)
},
onDismiss = { showKeyGenerationDialog = false },
)
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.security),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = Config(security = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.direct_message_key)) {
EditBase64Preference(
title = stringResource(Res.string.public_key),
summary = stringResource(Res.string.config_security_public_key),
value = publicKey,
enabled = state.connected,
readOnly = true,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size == 32) {
formState.value = formState.value.copy(public_key = it)
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.public_key.encodeToString()) },
)
HorizontalDivider()
EditBase64Preference(
title = stringResource(Res.string.private_key),
summary = stringResource(Res.string.config_security_private_key),
value = formState.value.private_key,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size == 32) {
formState.value = formState.value.copy(private_key = it)
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.private_key.encodeToString()) },
)
HorizontalDivider()
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(Res.string.regenerate_private_key),
enabled = state.connected,
icon = Icons.TwoTone.Warning,
onClick = { showKeyGenerationDialog = true },
)
}
}
item {
TitledCard(title = stringResource(Res.string.admin_keys)) {
EditListPreference(
title = stringResource(Res.string.admin_key),
summary = stringResource(Res.string.config_security_admin_key),
list = formState.value.admin_key,
maxCount = 3,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = { formState.value = formState.value.copy(admin_key = it) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.logs)) {
SwitchPreference(
title = stringResource(Res.string.serial_console),
summary = stringResource(Res.string.config_security_serial_enabled),
checked = formState.value.serial_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(serial_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.debug_log_api_enabled),
summary = stringResource(Res.string.config_security_debug_log_api_enabled),
checked = formState.value.debug_log_api_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(debug_log_api_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.administration)) {
SwitchPreference(
title = stringResource(Res.string.managed_mode),
summary = stringResource(Res.string.config_security_is_managed),
checked = formState.value.is_managed,
enabled = state.connected && formState.value.admin_key.isNotEmpty(),
onCheckedChange = { formState.value = formState.value.copy(is_managed = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.legacy_admin_channel),
checked = formState.value.admin_channel_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
}
@Suppress("MagicNumber")
@Composable
private fun DesktopPrivateKeyRegenerateDialog(onConfirm: (Config.SecurityConfig) -> Unit, onDismiss: () -> Unit = {}) {
MeshtasticResourceDialog(
onDismiss = onDismiss,
titleRes = Res.string.regenerate_private_key,
messageRes = Res.string.regenerate_keys_confirmation,
onConfirm = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput = Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
)
}

View file

@ -0,0 +1,383 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.modules_already_unlocked
import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import kotlin.time.Duration.Companion.seconds
/**
* Desktop-specific top-level settings screen. Replaces the Android `SettingsScreen` which uses Android-specific APIs
* (Activity, permissions, etc.).
*
* Shows radio configuration entry points that are fully shared in commonMain, plus app-level settings (theme,
* homoglyph, DB cache limit) and an App Info section (About link, version easter egg).
*/
@Suppress("LongMethod")
@Composable
fun DesktopSettingsScreen(
radioConfigViewModel: RadioConfigViewModel,
settingsViewModel: SettingsViewModel,
onNavigate: (Route) -> Unit,
) {
val state by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by radioConfigViewModel.destNode.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val homoglyphEnabled by radioConfigViewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val cacheLimit by settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle()
var showThemePickerDialog by remember { mutableStateOf(false) }
var showLanguagePickerDialog by remember { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
onDismiss = { showThemePickerDialog = false },
)
}
if (showLanguagePickerDialog) {
LanguagePickerDialog(
onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) },
onDismiss = { showLanguagePickerDialog = false },
)
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.bottom_nav_settings),
subtitle =
if (state.isLocal) {
null
} else {
val remoteName = destNode?.user?.long_name ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = null,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security?.is_managed ?: false,
isOtaCapable = false, // OTA not supported on Desktop yet
onRouteClick = { route ->
val navRoute =
when (route) {
is ConfigRoute -> route.route
is ModuleRoute -> route.route
else -> null
}
navRoute?.let { onNavigate(it) }
},
onNavigate = onNavigate,
onImport = {
// Profile import not yet supported on Desktop
},
onExport = {
// Profile export not yet supported on Desktop
},
)
// App-local settings are only relevant when configuring the local node
if (state.isLocal) {
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
ListItem(
text = stringResource(Res.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
showThemePickerDialog = true
}
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = null,
) {
showLanguagePickerDialog = true
}
HomoglyphSetting(
homoglyphEncodingEnabled = homoglyphEnabled,
onToggle = { radioConfigViewModel.toggleHomoglyphCharactersEncodingEnabled() },
)
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
it.toLong() to it.toString()
}
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
}
NotificationSection(
messagesEnabled = settingsViewModel.messagesEnabled.collectAsStateWithLifecycle().value,
onToggleMessages = { settingsViewModel.setMessagesEnabled(it) },
nodeEventsEnabled = settingsViewModel.nodeEventsEnabled.collectAsStateWithLifecycle().value,
onToggleNodeEvents = { settingsViewModel.setNodeEventsEnabled(it) },
lowBatteryEnabled = settingsViewModel.lowBatteryEnabled.collectAsStateWithLifecycle().value,
onToggleLowBattery = { settingsViewModel.setLowBatteryEnabled(it) },
)
DesktopAppInfoSection(
appVersionName = settingsViewModel.appVersionName,
excludedModulesUnlocked = excludedModulesUnlocked,
onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
)
}
}
}
}
/** Desktop App Info section: About link and version with excluded-modules unlock easter egg. */
@Composable
private fun DesktopAppInfoSection(
appVersionName: String,
excludedModulesUnlocked: Boolean,
onUnlockExcludedModules: () -> Unit,
onNavigateToAbout: () -> Unit,
) {
ExpressiveSection(title = stringResource(Res.string.info)) {
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigateToAbout()
}
DesktopAppVersionButton(
excludedModulesUnlocked = excludedModulesUnlocked,
appVersionName = appVersionName,
onUnlockExcludedModules = onUnlockExcludedModules,
)
}
}
private const val UNLOCK_CLICK_COUNT = 5
private const val UNLOCKED_CLICK_COUNT = 3
private const val UNLOCK_TIMEOUT_SECONDS = 1
@Composable
private fun DesktopAppVersionButton(
excludedModulesUnlocked: Boolean,
appVersionName: String,
onUnlockExcludedModules: () -> Unit,
) {
val scope = rememberCoroutineScope()
val showToast = rememberShowToastResource()
var clickCount by remember { mutableStateOf(0) }
LaunchedEffect(clickCount) {
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
clickCount = 0
}
}
ListItem(
text = stringResource(Res.string.app_version),
leadingIcon = Icons.Rounded.Memory,
supportingText = appVersionName,
trailingIcon = null,
) {
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
when {
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
clickCount = 0
scope.launch { showToast(Res.string.modules_already_unlocked) }
}
clickCount == UNLOCK_CLICK_COUNT -> {
clickCount = 0
onUnlockExcludedModules()
scope.launch { showToast(Res.string.modules_unlocked) }
}
}
}
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = 1), // MODE_NIGHT_NO
DARK(label = Res.string.theme_dark, mode = 2), // MODE_NIGHT_YES
SYSTEM(label = Res.string.theme_system, mode = -1), // MODE_NIGHT_FOLLOW_SYSTEM
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}
/**
* Supported languages tag must match the CMP `values-<qualifier>` directory names. Empty tag means system default.
* Display names are written in the native language for clarity.
*/
private val SUPPORTED_LANGUAGES =
listOf(
"" to "System default",
"ar" to "العربية",
"be" to "Беларуская",
"bg" to "Български",
"ca" to "Català",
"cs" to "Čeština",
"de" to "Deutsch",
"el" to "Ελληνικά",
"en" to "English",
"es" to "Español",
"et" to "Eesti",
"fi" to "Suomi",
"fr" to "Français",
"ga" to "Gaeilge",
"gl" to "Galego",
"he" to "עברית",
"hr" to "Hrvatski",
"ht" to "Kreyòl Ayisyen",
"hu" to "Magyar",
"is" to "Íslenska",
"it" to "Italiano",
"ja" to "日本語",
"ko" to "한국어",
"lt" to "Lietuvių",
"nl" to "Nederlands",
"no" to "Norsk",
"pl" to "Polski",
"pt" to "Português",
"pt-BR" to "Português (Brasil)",
"ro" to "Română",
"ru" to "Русский",
"sk" to "Slovenčina",
"sl" to "Slovenščina",
"sq" to "Shqip",
"sr" to "Српски",
"sv" to "Svenska",
"tr" to "Türkçe",
"uk" to "Українська",
"zh-CN" to "中文 (简体)",
"zh-TW" to "中文 (繁體)",
)
@Composable
private fun LanguagePickerDialog(onSelectLanguage: (String) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.preferences_language),
onDismiss = onDismiss,
text = {
LazyColumn {
items(SUPPORTED_LANGUAGES) { (tag, displayName) ->
ListItem(text = displayName, trailingIcon = null) {
onSelectLanguage(tag)
onDismiss()
}
}
}
},
)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import org.meshtastic.core.navigation.SettingsRoutes
actual fun getAboutLibrariesJson(): String =
SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.settings.DesktopDeviceConfigScreen
import org.meshtastic.feature.settings.DesktopExternalNotificationConfigScreen
import org.meshtastic.feature.settings.DesktopPositionConfigScreen
import org.meshtastic.feature.settings.DesktopSecurityConfigScreen
import org.meshtastic.feature.settings.DesktopSettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
actual fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
) {
DesktopSettingsScreen(
settingsViewModel = settingsViewModel,
radioConfigViewModel = radioConfigViewModel,
onNavigate = onNavigate,
)
}
@Composable
actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopDeviceConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopPositionConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopSecurityConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack)
}