Modularize settings code (#3355)

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Phil Oliver 2025-10-06 13:20:03 -04:00 committed by GitHub
parent 4613a26c9d
commit 95ec4877df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 444 additions and 358 deletions

View file

@ -1,405 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.model
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.StoreAndForwardProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.ui.debug.FilterMode
import com.google.protobuf.InvalidProtocolBufferException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MeshLog
import timber.log.Timber
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String)
data class SearchState(
val searchText: String = "",
val currentMatchIndex: Int = -1,
val allMatches: List<SearchMatch> = emptyList(),
val hasMatches: Boolean = false,
)
// --- Search and Filter Managers ---
class LogSearchManager {
data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String)
data class SearchState(
val searchText: String = "",
val currentMatchIndex: Int = -1,
val allMatches: List<SearchMatch> = emptyList(),
val hasMatches: Boolean = false,
)
private val _searchText = MutableStateFlow("")
val searchText = _searchText.asStateFlow()
private val _currentMatchIndex = MutableStateFlow(-1)
val currentMatchIndex = _currentMatchIndex.asStateFlow()
private val _searchState = MutableStateFlow(SearchState())
val searchState = _searchState.asStateFlow()
fun setSearchText(text: String) {
_searchText.value = text
_currentMatchIndex.value = -1
}
fun goToNextMatch() {
val matches = _searchState.value.allMatches
if (matches.isNotEmpty()) {
val nextIndex = if (_currentMatchIndex.value < matches.lastIndex) _currentMatchIndex.value + 1 else 0
_currentMatchIndex.value = nextIndex
_searchState.value = _searchState.value.copy(currentMatchIndex = nextIndex)
}
}
fun goToPreviousMatch() {
val matches = _searchState.value.allMatches
if (matches.isNotEmpty()) {
val prevIndex = if (_currentMatchIndex.value > 0) _currentMatchIndex.value - 1 else matches.lastIndex
_currentMatchIndex.value = prevIndex
_searchState.value = _searchState.value.copy(currentMatchIndex = prevIndex)
}
}
fun clearSearch() {
setSearchText("")
}
fun updateMatches(searchText: String, filteredLogs: List<DebugViewModel.UiMeshLog>) {
val matches = findSearchMatches(searchText, filteredLogs)
val hasMatches = matches.isNotEmpty()
_searchState.value =
_searchState.value.copy(
searchText = searchText,
allMatches = matches,
hasMatches = hasMatches,
currentMatchIndex = if (hasMatches) _currentMatchIndex.value.coerceIn(0, matches.lastIndex) else -1,
)
}
fun findSearchMatches(searchText: String, filteredLogs: List<DebugViewModel.UiMeshLog>): List<SearchMatch> {
if (searchText.isEmpty()) {
return emptyList()
}
return filteredLogs
.flatMapIndexed { logIndex, log ->
searchText.split(" ").flatMap { term ->
val escapedTerm = Regex.escape(term)
val regex = escapedTerm.toRegex(RegexOption.IGNORE_CASE)
val messageMatches =
regex.findAll(log.logMessage).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "message")
}
val typeMatches =
regex.findAll(log.messageType).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "type")
}
val dateMatches =
regex.findAll(log.formattedReceivedDate).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "date")
}
val decodedPayloadMatches =
log.decodedPayload?.let { decoded ->
regex.findAll(decoded).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "decodedPayload")
}
} ?: emptySequence()
messageMatches + typeMatches + dateMatches + decodedPayloadMatches
}
}
.sortedBy { it.start }
}
}
class LogFilterManager {
private val _filterTexts = MutableStateFlow<List<String>>(emptyList())
val filterTexts = _filterTexts.asStateFlow()
private val _filteredLogs = MutableStateFlow<List<DebugViewModel.UiMeshLog>>(emptyList())
val filteredLogs = _filteredLogs.asStateFlow()
fun setFilterTexts(filters: List<String>) {
_filterTexts.value = filters
}
fun updateFilteredLogs(logs: List<DebugViewModel.UiMeshLog>) {
_filteredLogs.value = logs
}
fun filterLogs(
logs: List<DebugViewModel.UiMeshLog>,
filterTexts: List<String>,
filterMode: FilterMode,
): List<DebugViewModel.UiMeshLog> {
if (filterTexts.isEmpty()) return logs
return logs.filter { log ->
when (filterMode) {
FilterMode.OR ->
filterTexts.any { filterText ->
log.logMessage.contains(filterText, ignoreCase = true) ||
log.messageType.contains(filterText, ignoreCase = true) ||
log.formattedReceivedDate.contains(filterText, ignoreCase = true) ||
(log.decodedPayload?.contains(filterText, ignoreCase = true) == true)
}
FilterMode.AND ->
filterTexts.all { filterText ->
log.logMessage.contains(filterText, ignoreCase = true) ||
log.messageType.contains(filterText, ignoreCase = true) ||
log.formattedReceivedDate.contains(filterText, ignoreCase = true) ||
(log.decodedPayload?.contains(filterText, ignoreCase = true) == true)
}
}
}
}
}
private const val HEX_FORMAT = "%02x"
@Suppress("TooManyFunctions")
@HiltViewModel
class DebugViewModel
@Inject
constructor(
private val meshLogRepository: MeshLogRepository,
private val nodeRepository: NodeRepository,
) : ViewModel() {
val meshLog: StateFlow<ImmutableList<UiMeshLog>> =
meshLogRepository
.getAllLogs()
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf())
// --- Managers ---
val searchManager = LogSearchManager()
val filterManager = LogFilterManager()
val searchText
get() = searchManager.searchText
val currentMatchIndex
get() = searchManager.currentMatchIndex
val searchState
get() = searchManager.searchState
val filterTexts
get() = filterManager.filterTexts
val filteredLogs
get() = filterManager.filteredLogs
private val _selectedLogId = MutableStateFlow<String?>(null)
val selectedLogId = _selectedLogId.asStateFlow()
fun updateFilteredLogs(logs: List<UiMeshLog>) {
filterManager.updateFilteredLogs(logs)
searchManager.updateMatches(searchManager.searchText.value, logs)
}
init {
Timber.d("DebugViewModel created")
viewModelScope.launch {
combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs ->
searchManager.findSearchMatches(searchText, logs)
}
.collect { matches ->
searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value)
}
}
}
override fun onCleared() {
super.onCleared()
Timber.d("DebugViewModel cleared")
}
private fun toUiState(databaseLogs: List<MeshLog>) = databaseLogs
.map { log ->
UiMeshLog(
uuid = log.uuid,
messageType = log.message_type,
formattedReceivedDate = TIME_FORMAT.format(log.received_date),
logMessage = annotateMeshLogMessage(log),
decodedPayload = decodePayloadFromMeshLog(log),
)
}
.toImmutableList()
/** Transform the input [MeshLog] by enhancing the raw message with annotations. */
private fun annotateMeshLogMessage(meshLog: MeshLog): String = when (meshLog.message_type) {
"Packet" -> meshLog.meshPacket?.let { packet -> annotatePacketLog(packet) } ?: meshLog.raw_message
"NodeInfo" ->
meshLog.nodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.num) }
?: meshLog.raw_message
"MyNodeInfo" ->
meshLog.myNodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) }
?: meshLog.raw_message
else -> meshLog.raw_message
}
private fun annotatePacketLog(packet: MeshProtos.MeshPacket): String {
val builder = packet.toBuilder()
val hasDecoded = builder.hasDecoded()
val decoded = if (hasDecoded) builder.decoded else null
if (hasDecoded) builder.clearDecoded()
val baseText = builder.build().toString().trimEnd()
val result =
if (hasDecoded && decoded != null) {
val decodedText = decoded.toString().trimEnd().prependIndent(" ")
"$baseText\ndecoded {\n$decodedText\n}"
} else {
baseText
}
return annotateRawMessage(result, packet.from, packet.to)
}
/** Annotate the raw message string with the node IDs provided, in hex, if they are present. */
private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String {
val msg = StringBuilder(rawMessage)
var mutated = false
nodeIds.toSet().forEach { nodeId -> mutated = mutated or msg.annotateNodeId(nodeId) }
return if (mutated) {
return msg.toString()
} else {
rawMessage
}
}
/** Look for a single node ID integer in the string and annotate it with the hex equivalent if found. */
private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean {
val nodeIdStr = nodeId.toUInt().toString()
// Only match if whitespace before and after
val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""")
regex.find(this)?.let { matchResult ->
matchResult.groupValues.let { _ ->
regex.findAll(this).toList().asReversed().forEach { match ->
val idx = match.range.last + 1
insert(idx, " (${nodeId.asNodeId()})")
}
}
return true
}
return false
}
private fun Int.asNodeId(): String = "!%08x".format(Locale.getDefault(), this)
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() }
@Immutable
data class UiMeshLog(
val uuid: String,
val messageType: String,
val formattedReceivedDate: String,
val logMessage: String,
val decodedPayload: String? = null,
)
companion object {
private val TIME_FORMAT = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val presetFilters: List<String>
get() = buildList {
// Our address if available
nodeRepository.myNodeInfo.value?.myNodeNum?.let { add("!%08x".format(it)) }
// broadcast
add("!ffffffff")
// decoded
add("decoded")
// today (locale-dependent short date format)
add(DateFormat.getDateInstance(DateFormat.SHORT).format(Date()))
// Each app name
addAll(PortNum.entries.map { it.name })
}
fun setSelectedLogId(id: String?) {
_selectedLogId.value = id
}
/**
* Attempts to fully decode the payload of a MeshLog's MeshPacket using the appropriate protobuf definition, based
* on the portnum of the packet.
*
* For known portnums, the payload is parsed into its corresponding proto message and returned as a string. For text
* and alert messages, the payload is interpreted as UTF-8 text. For unknown portnums, the payload is shown as a hex
* string.
*
* @param log The MeshLog containing the packet and payload to decode.
* @return A human-readable string representation of the decoded payload, or an error message if decoding fails, or
* null if the log does not contain a decodable packet.
*/
private fun decodePayloadFromMeshLog(log: MeshLog): String? {
var result: String? = null
val packet = log.meshPacket
if (packet == null || !packet.hasDecoded()) {
result = null
} else {
val portnum = packet.decoded.portnumValue
val payload = packet.decoded.payload.toByteArray()
result =
try {
when (portnum) {
PortNum.TEXT_MESSAGE_APP_VALUE,
PortNum.ALERT_APP_VALUE,
-> payload.toString(Charsets.UTF_8)
PortNum.POSITION_APP_VALUE -> MeshProtos.Position.parseFrom(payload).toString()
PortNum.WAYPOINT_APP_VALUE -> MeshProtos.Waypoint.parseFrom(payload).toString()
PortNum.NODEINFO_APP_VALUE -> MeshProtos.User.parseFrom(payload).toString()
PortNum.TELEMETRY_APP_VALUE -> TelemetryProtos.Telemetry.parseFrom(payload).toString()
PortNum.ROUTING_APP_VALUE -> MeshProtos.Routing.parseFrom(payload).toString()
PortNum.ADMIN_APP_VALUE -> AdminProtos.AdminMessage.parseFrom(payload).toString()
PortNum.PAXCOUNTER_APP_VALUE -> PaxcountProtos.Paxcount.parseFrom(payload).toString()
PortNum.STORE_FORWARD_APP_VALUE ->
StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString()
else -> payload.joinToString(" ") { HEX_FORMAT.format(it) }
}
} catch (e: InvalidProtocolBufferException) {
"Failed to decode payload: ${e.message}"
}
}
return result
}
}

View file

@ -29,15 +29,11 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ChannelProtos.ChannelSettings
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.channel
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.repository.radio.MeshActivity
@ -106,34 +102,6 @@ fun getInitials(fullName: String): String {
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
/**
* Builds a [Channel] list from the difference between two [ChannelSettings] lists. Only changes are included in the
* resulting list.
*
* @param new The updated [ChannelSettings] list.
* @param old The current [ChannelSettings] list (required when disabling unused channels).
* @return A [Channel] list containing only the modified channels.
*/
internal fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List<ChannelProtos.Channel> =
buildList {
for (i in 0..maxOf(old.lastIndex, new.lastIndex)) {
if (old.getOrNull(i) != new.getOrNull(i)) {
add(
channel {
role =
when (i) {
0 -> ChannelProtos.Channel.Role.PRIMARY
in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY
else -> ChannelProtos.Channel.Role.DISABLED
}
index = i
settings = new.getOrNull(i) ?: channelSettings {}
},
)
}
}
}
data class Contact(
val contactKey: String,
val shortName: String,

View file

@ -24,11 +24,12 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.geeksville.mesh.ui.settings.radio.components.ChannelConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.LoRaConfigScreen
import com.geeksville.mesh.ui.sharing.ChannelScreen
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.radio.component.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
/** Navigation graph for for the top level ChannelScreen - [ChannelsRoutes.Channels]. */
fun NavGraphBuilder.channelsGraph(navController: NavHostController) {

View file

@ -25,12 +25,12 @@ import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.geeksville.mesh.ui.connections.ConnectionsScreen
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.LoRaConfigScreen
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
/** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoutes.Connections]. */
fun NavGraphBuilder.connectionsGraph(navController: NavHostController) {

View file

@ -1,529 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.navigation
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Forward
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.DataUsage
import androidx.compose.material.icons.filled.DisplaySettings
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.PermScanWifi
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.SettingsRemote
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Usb
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.MeshProtos.DeviceMetadata
import com.geeksville.mesh.ui.debug.DebugScreen
import com.geeksville.mesh.ui.settings.SettingsScreen
import com.geeksville.mesh.ui.settings.radio.CleanNodeDatabaseScreen
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.AmbientLightingConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.AudioConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.BluetoothConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.CannedMessageConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.ChannelConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.DetectionSensorConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.DeviceConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.DisplayConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.ExternalNotificationConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.LoRaConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.MQTTConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.NeighborInfoConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.NetworkConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.PaxcounterConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.PositionConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.PowerConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.RangeTestConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.RemoteHardwareConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.SecurityConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.SerialConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.StoreForwardConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.TelemetryConfigScreen
import com.geeksville.mesh.ui.settings.radio.components.UserConfigScreen
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.R
fun getNavRouteFrom(routeName: String): Route? =
ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route
@Suppress("LongMethod")
fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
navigation<SettingsRoutes.SettingsGraph>(startDestination = SettingsRoutes.Settings()) {
composable<SettingsRoutes.Settings>(
deepLinks = listOf(navDeepLink<SettingsRoutes.Settings>(basePath = "$DEEP_LINK_BASE_URI/settings")),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
) {
navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
}
}
composable<SettingsRoutes.CleanNodeDb>(
deepLinks =
listOf(
navDeepLink<SettingsRoutes.CleanNodeDb>(
basePath = "$DEEP_LINK_BASE_URI/settings/radio/clean_node_db",
),
),
) {
CleanNodeDatabaseScreen()
}
configRoutesScreens(navController)
moduleRoutesScreens(navController)
composable<SettingsRoutes.DebugPanel>(
deepLinks =
listOf(navDeepLink<SettingsRoutes.DebugPanel>(basePath = "$DEEP_LINK_BASE_URI/settings/debug_panel")),
) {
DebugScreen(onNavigateUp = navController::navigateUp)
}
}
}
fun NavDestination.isConfigRoute(): Boolean =
ConfigRoute.entries.any { hasRoute(it.route::class) } || ModuleRoute.entries.any { hasRoute(it.route::class) }
/**
* Helper to define a composable route for a radio configuration screen within the radio config graph.
*
* This function simplifies adding screens by handling common tasks like:
* - Setting up deep links based on the route's name.
* - Retrieving the parent [NavBackStackEntry] for the [SettingsRoutes.SettingsGraph].
* - Providing the [RadioConfigViewModel] scoped to the parent graph, which the [screenContent] will use.
*
* @param R The type of the [Route] object, must be serializable.
* @param navController The [NavHostController] for navigation.
* @param routeNameString The string name of the route (from the enum entry's name) used for deep link paths.
* @param screenContent A lambda that defines the composable content for the screen. It receives the parent-scoped
* [RadioConfigViewModel].
*/
private inline fun <reified R : Route> NavGraphBuilder.addRadioConfigScreenComposable(
navController: NavHostController,
routeNameString: String,
crossinline screenContent: @Composable (navController: NavController, viewModel: RadioConfigViewModel) -> Unit,
) {
composable<R>(
deepLinks =
listOf(
navDeepLink<R>(
basePath = "$DEEP_LINK_BASE_URI/settings/radio/{destNum}/${routeNameString.lowercase()}",
),
navDeepLink<R>(basePath = "$DEEP_LINK_BASE_URI/settings/radio/${routeNameString.lowercase()}"),
),
) { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
val viewModel = hiltViewModel<RadioConfigViewModel>(parentEntry)
screenContent(navController, viewModel)
}
}
@Suppress("LongMethod")
private fun NavGraphBuilder.configRoutesScreens(navController: NavHostController) {
ConfigRoute.entries.forEach { entry ->
when (entry.route) {
is SettingsRoutes.User ->
addRadioConfigScreenComposable<SettingsRoutes.User>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.ChannelConfig ->
addRadioConfigScreenComposable<SettingsRoutes.ChannelConfig>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Device ->
addRadioConfigScreenComposable<SettingsRoutes.Device>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.Position ->
addRadioConfigScreenComposable<SettingsRoutes.Position>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Power ->
addRadioConfigScreenComposable<SettingsRoutes.Power>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.Network ->
addRadioConfigScreenComposable<SettingsRoutes.Network>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Display ->
addRadioConfigScreenComposable<SettingsRoutes.Display>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.LoRa ->
addRadioConfigScreenComposable<SettingsRoutes.LoRa>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.Bluetooth ->
addRadioConfigScreenComposable<SettingsRoutes.Bluetooth>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Security ->
addRadioConfigScreenComposable<SettingsRoutes.Security>(
navController,
entry.name,
entry.screenComposable,
)
else -> Unit // Should not happen if ConfigRoute enum is exhaustive for this context
}
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun NavGraphBuilder.moduleRoutesScreens(navController: NavHostController) {
ModuleRoute.entries.forEach { entry ->
when (entry.route) {
is SettingsRoutes.MQTT ->
addRadioConfigScreenComposable<SettingsRoutes.MQTT>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.Serial ->
addRadioConfigScreenComposable<SettingsRoutes.Serial>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.ExtNotification ->
addRadioConfigScreenComposable<SettingsRoutes.ExtNotification>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.StoreForward ->
addRadioConfigScreenComposable<SettingsRoutes.StoreForward>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.RangeTest ->
addRadioConfigScreenComposable<SettingsRoutes.RangeTest>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Telemetry ->
addRadioConfigScreenComposable<SettingsRoutes.Telemetry>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.CannedMessage ->
addRadioConfigScreenComposable<SettingsRoutes.CannedMessage>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Audio ->
addRadioConfigScreenComposable<SettingsRoutes.Audio>(navController, entry.name, entry.screenComposable)
is SettingsRoutes.RemoteHardware ->
addRadioConfigScreenComposable<SettingsRoutes.RemoteHardware>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.NeighborInfo ->
addRadioConfigScreenComposable<SettingsRoutes.NeighborInfo>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.AmbientLighting ->
addRadioConfigScreenComposable<SettingsRoutes.AmbientLighting>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.DetectionSensor ->
addRadioConfigScreenComposable<SettingsRoutes.DetectionSensor>(
navController,
entry.name,
entry.screenComposable,
)
is SettingsRoutes.Paxcounter ->
addRadioConfigScreenComposable<SettingsRoutes.Paxcounter>(
navController,
entry.name,
entry.screenComposable,
)
else -> Unit // Should not happen if ModuleRoute enum is exhaustive for this context
}
}
}
@Suppress("MagicNumber")
enum class ConfigRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
val type: Int = 0,
val screenComposable: @Composable (navController: NavController, viewModel: RadioConfigViewModel) -> Unit,
) {
USER(R.string.user, SettingsRoutes.User, Icons.Default.Person, 0, { nc, vm -> UserConfigScreen(nc, vm) }),
CHANNELS(
R.string.channels,
SettingsRoutes.ChannelConfig,
Icons.AutoMirrored.Default.List,
0,
{ nc, vm -> ChannelConfigScreen(nc, vm) },
),
DEVICE(
R.string.device,
SettingsRoutes.Device,
Icons.Default.Router,
AdminProtos.AdminMessage.ConfigType.DEVICE_CONFIG_VALUE,
{ nc, vm -> DeviceConfigScreen(nc, vm) },
),
POSITION(
R.string.position,
SettingsRoutes.Position,
Icons.Default.LocationOn,
AdminProtos.AdminMessage.ConfigType.POSITION_CONFIG_VALUE,
{ nc, vm -> PositionConfigScreen(nc, vm) },
),
POWER(
R.string.power,
SettingsRoutes.Power,
Icons.Default.Power,
AdminProtos.AdminMessage.ConfigType.POWER_CONFIG_VALUE,
{ nc, vm -> PowerConfigScreen(nc, vm) },
),
NETWORK(
R.string.network,
SettingsRoutes.Network,
Icons.Default.Wifi,
AdminProtos.AdminMessage.ConfigType.NETWORK_CONFIG_VALUE,
{ nc, vm -> NetworkConfigScreen(nc, vm) },
),
DISPLAY(
R.string.display,
SettingsRoutes.Display,
Icons.Default.DisplaySettings,
AdminProtos.AdminMessage.ConfigType.DISPLAY_CONFIG_VALUE,
{ nc, vm -> DisplayConfigScreen(nc, vm) },
),
LORA(
R.string.lora,
SettingsRoutes.LoRa,
Icons.Default.CellTower,
AdminProtos.AdminMessage.ConfigType.LORA_CONFIG_VALUE,
{ nc, vm -> LoRaConfigScreen(nc, vm) },
),
BLUETOOTH(
R.string.bluetooth,
SettingsRoutes.Bluetooth,
Icons.Default.Bluetooth,
AdminProtos.AdminMessage.ConfigType.BLUETOOTH_CONFIG_VALUE,
{ nc, vm -> BluetoothConfigScreen(nc, vm) },
),
SECURITY(
R.string.security,
SettingsRoutes.Security,
Icons.Default.Security,
AdminProtos.AdminMessage.ConfigType.SECURITY_CONFIG_VALUE,
{ nc, vm -> SecurityConfigScreen(nc, vm) },
),
;
companion object {
private fun filterExcludedFrom(metadata: DeviceMetadata?): List<ConfigRoute> = entries.filter {
when {
metadata == null -> true // Include all routes if metadata is null
it == BLUETOOTH -> metadata.hasBluetooth
it == NETWORK -> metadata.hasWifi || metadata.hasEthernet
else -> true // Include all other routes by default
}
}
val radioConfigRoutes = listOf(LORA, CHANNELS, SECURITY)
fun deviceConfigRoutes(metadata: DeviceMetadata?): List<ConfigRoute> =
filterExcludedFrom(metadata) - radioConfigRoutes
}
}
@Suppress("MagicNumber")
enum class ModuleRoute(
@StringRes val title: Int,
val route: Route,
val icon: ImageVector?,
val type: Int = 0,
val screenComposable: @Composable (navController: NavController, viewModel: RadioConfigViewModel) -> Unit,
) {
MQTT(
R.string.mqtt,
SettingsRoutes.MQTT,
Icons.Default.Cloud,
AdminProtos.AdminMessage.ModuleConfigType.MQTT_CONFIG_VALUE,
{ nc, vm -> MQTTConfigScreen(nc, vm) },
),
SERIAL(
R.string.serial,
SettingsRoutes.Serial,
Icons.Default.Usb,
AdminProtos.AdminMessage.ModuleConfigType.SERIAL_CONFIG_VALUE,
{ nc, vm -> SerialConfigScreen(nc, vm) },
),
EXT_NOTIFICATION(
R.string.external_notification,
SettingsRoutes.ExtNotification,
Icons.Default.Notifications,
AdminProtos.AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG_VALUE,
{ nc, vm -> ExternalNotificationConfigScreen(nc, vm) },
),
STORE_FORWARD(
R.string.store_forward,
SettingsRoutes.StoreForward,
Icons.AutoMirrored.Default.Forward,
AdminProtos.AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG_VALUE,
{ nc, vm -> StoreForwardConfigScreen(nc, vm) },
),
RANGE_TEST(
R.string.range_test,
SettingsRoutes.RangeTest,
Icons.Default.Speed,
AdminProtos.AdminMessage.ModuleConfigType.RANGETEST_CONFIG_VALUE,
{ nc, vm -> RangeTestConfigScreen(nc, vm) },
),
TELEMETRY(
R.string.telemetry,
SettingsRoutes.Telemetry,
Icons.Default.DataUsage,
AdminProtos.AdminMessage.ModuleConfigType.TELEMETRY_CONFIG_VALUE,
{ nc, vm -> TelemetryConfigScreen(nc, vm) },
),
CANNED_MESSAGE(
R.string.canned_message,
SettingsRoutes.CannedMessage,
Icons.AutoMirrored.Default.Message,
AdminProtos.AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG_VALUE,
{ nc, vm -> CannedMessageConfigScreen(nc, vm) },
),
AUDIO(
R.string.audio,
SettingsRoutes.Audio,
Icons.AutoMirrored.Default.VolumeUp,
AdminProtos.AdminMessage.ModuleConfigType.AUDIO_CONFIG_VALUE,
{ nc, vm -> AudioConfigScreen(nc, vm) },
),
REMOTE_HARDWARE(
R.string.remote_hardware,
SettingsRoutes.RemoteHardware,
Icons.Default.SettingsRemote,
AdminProtos.AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG_VALUE,
{ nc, vm -> RemoteHardwareConfigScreen(nc, vm) },
),
NEIGHBOR_INFO(
R.string.neighbor_info,
SettingsRoutes.NeighborInfo,
Icons.Default.People,
AdminProtos.AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG_VALUE,
{ nc, vm -> NeighborInfoConfigScreen(nc, vm) },
),
AMBIENT_LIGHTING(
R.string.ambient_lighting,
SettingsRoutes.AmbientLighting,
Icons.Default.LightMode,
AdminProtos.AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG_VALUE,
{ nc, vm -> AmbientLightingConfigScreen(nc, vm) },
),
DETECTION_SENSOR(
R.string.detection_sensor,
SettingsRoutes.DetectionSensor,
Icons.Default.Sensors,
AdminProtos.AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG_VALUE,
{ nc, vm -> DetectionSensorConfigScreen(nc, vm) },
),
PAXCOUNTER(
R.string.paxcounter,
SettingsRoutes.Paxcounter,
Icons.Default.PermScanWifi,
AdminProtos.AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG_VALUE,
{ nc, vm -> PaxcounterConfigScreen(nc, vm) },
),
;
val bitfield: Int
get() = 1 shl ordinal
companion object {
fun filterExcludedFrom(metadata: DeviceMetadata?): List<ModuleRoute> = entries.filter {
when (metadata) {
null -> true // Include all routes if metadata is null
else -> metadata.excludedModules and it.bitfield == 0
}
}
}
}

View file

@ -1,119 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.repository.location
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.app.Application
import android.location.LocationManager
import androidx.annotation.RequiresPermission
import androidx.core.location.LocationCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import androidx.core.location.altitude.AltitudeConverterCompat
import com.geeksville.mesh.MeshUtilApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationRepository
@Inject
constructor(
private val context: Application,
private val locationManager: dagger.Lazy<LocationManager>,
) {
/** Status of whether the app is actively subscribed to location changes. */
private val _receivingLocationUpdates: MutableStateFlow<Boolean> = MutableStateFlow(false)
val receivingLocationUpdates: StateFlow<Boolean>
get() = _receivingLocationUpdates
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
private fun LocationManager.requestLocationUpdates() = callbackFlow {
val intervalMs = 30 * 1000L // 30 seconds
val minDistanceM = 0f
val locationRequest =
LocationRequestCompat.Builder(intervalMs)
.setMinUpdateDistanceMeters(minDistanceM)
.setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY)
.build()
val locationListener = LocationListenerCompat { location ->
if (location.hasAltitude() && !LocationCompat.hasMslAltitude(location)) {
try {
AltitudeConverterCompat.addMslAltitudeToLocation(context, location)
} catch (e: Exception) {
Timber.e(e, "addMslAltitudeToLocation() failed")
}
}
// info("New location: $location")
trySend(location)
}
val providerList = buildList {
val providers = allProviders
if (android.os.Build.VERSION.SDK_INT >= 31 && LocationManager.FUSED_PROVIDER in providers) {
add(LocationManager.FUSED_PROVIDER)
} else {
if (LocationManager.GPS_PROVIDER in providers) add(LocationManager.GPS_PROVIDER)
if (LocationManager.NETWORK_PROVIDER in providers) add(LocationManager.NETWORK_PROVIDER)
}
}
Timber.i(
"Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m",
)
_receivingLocationUpdates.value = true
MeshUtilApplication.analytics.track("location_start") // Figure out how many users needed to use the phone GPS
try {
providerList.forEach { provider ->
LocationManagerCompat.requestLocationUpdates(
this@requestLocationUpdates,
provider,
locationRequest,
Dispatchers.IO.asExecutor(),
locationListener,
)
}
} catch (e: Exception) {
close(e) // in case of exception, close the Flow
}
awaitClose {
Timber.i("Stopping location requests")
_receivingLocationUpdates.value = false
MeshUtilApplication.analytics.track("location_stop")
LocationManagerCompat.removeUpdates(this@requestLocationUpdates, locationListener)
}
}
/** Observable flow for location updates */
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
fun getLocations() = locationManager.get().requestLocationUpdates()
}

View file

@ -1,37 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.repository.location
import android.content.Context
import android.location.LocationManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object LocationRepositoryModule {
@Provides
@Singleton
fun provideLocationManager(@ApplicationContext context: Context): LocationManager =
context.applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
}

View file

@ -37,6 +37,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.util.anonymize
import timber.log.Timber
import java.lang.reflect.Method
@ -104,6 +105,7 @@ constructor(
context: Application,
bluetoothRepository: BluetoothRepository,
private val service: RadioInterfaceService,
analytics: PlatformAnalytics,
@Assisted val address: String,
) : IRadioInterface {
@ -195,7 +197,7 @@ constructor(
Timber.i("Creating radio interface service. device=${address.anonymize}")
// Note this constructor also does no comm
val s = SafeBluetooth(context, device)
val s = SafeBluetooth(context, device, analytics)
safe = s
startConnect()

View file

@ -40,8 +40,6 @@ import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.FromRadio.PayloadVariantCase
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.ToRadio
import com.geeksville.mesh.MeshUtilApplication
import com.geeksville.mesh.MeshUtilApplication.Companion.analytics
import com.geeksville.mesh.ModuleConfigProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums
@ -54,7 +52,6 @@ import com.geeksville.mesh.copy
import com.geeksville.mesh.fromRadio
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.position
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.network.MQTTRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.telemetry
@ -77,7 +74,9 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
@ -152,6 +151,8 @@ class MeshService : Service() {
@Inject lateinit var serviceBroadcasts: MeshServiceBroadcasts
@Inject lateinit var analytics: PlatformAnalytics
private val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
companion object {
@ -869,13 +870,9 @@ class MeshService : Service() {
serviceBroadcasts.broadcastReceivedData(dataPacket)
}
MeshUtilApplication.analytics.track("num_data_receive", DataPair("num_data_receive", 1))
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
MeshUtilApplication.analytics.track(
"data_receive",
DataPair("num_bytes", bytes.size),
DataPair("type", data.portnumValue),
)
analytics.track("data_receive", DataPair("num_bytes", bytes.size), DataPair("type", data.portnumValue))
}
}
}
@ -1122,7 +1119,7 @@ class MeshService : Service() {
sendNow(p)
sentPackets.add(p)
} catch (ex: Exception) {
Timber.e("Error sending queued message:", ex)
Timber.e(ex, "Error sending queued message:")
}
}
offlineSentPackets.removeAll(sentPackets)
@ -1239,7 +1236,7 @@ class MeshService : Service() {
/** Send in analytics about mesh connection */
private fun reportConnection() {
val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown")
MeshUtilApplication.analytics.track(
analytics.track(
"mesh_connect",
DataPair("num_nodes", numNodes),
DataPair("num_online", numOnlineNodes),
@ -1266,10 +1263,7 @@ class MeshService : Service() {
val now = System.currentTimeMillis()
connectTimeMsec = 0L
MeshUtilApplication.analytics.track(
"connected_seconds",
DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0),
)
analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0))
}
// Have our timeout fire in the appropriate number of seconds
@ -1298,12 +1292,8 @@ class MeshService : Service() {
stopLocationRequests()
stopMqttClientProxy()
MeshUtilApplication.analytics.track(
"mesh_disconnect",
DataPair("num_nodes", numNodes),
DataPair("num_online", numOnlineNodes),
)
MeshUtilApplication.analytics.track("num_nodes", DataPair("num_nodes", numNodes))
analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes))
analytics.track("num_nodes", DataPair("num_nodes", numNodes))
// broadcast an intent with our new connection state
serviceBroadcasts.broadcastConnection()
@ -1315,7 +1305,7 @@ class MeshService : Service() {
connectTimeMsec = System.currentTimeMillis()
startConfig()
} catch (ex: InvalidProtocolBufferException) {
Timber.e("Invalid protocol buffer sent by device - update device software and try again", ex)
Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again")
} catch (ex: RadioNotConnectedException) {
// note: no need to call startDeviceSleep(), because this exception could only have
// reached us if it was
@ -2104,7 +2094,7 @@ class MeshService : Service() {
try {
sendNow(p)
} catch (ex: Exception) {
Timber.e("Error sending message, so enqueueing", ex)
Timber.e(ex, "Error sending message, so enqueueing")
enqueueForSending(p)
}
} else {
@ -2115,11 +2105,7 @@ class MeshService : Service() {
// Keep a record of DataPackets, so GUIs can show proper chat history
rememberDataPacket(p, false)
MeshUtilApplication.analytics.track(
"data_send",
DataPair("num_bytes", bytes.size),
DataPair("type", p.dataType),
)
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
}

View file

@ -31,7 +31,6 @@ import android.os.Build
import android.os.DeadObjectException
import android.os.Handler
import android.os.Looper
import com.geeksville.mesh.MeshUtilApplication.Companion.analytics
import com.geeksville.mesh.concurrent.CallbackContinuation
import com.geeksville.mesh.concurrent.Continuation
import com.geeksville.mesh.concurrent.SyncContinuation
@ -43,6 +42,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import timber.log.Timber
import java.io.Closeable
import java.util.Random
@ -63,7 +63,11 @@ fun longBLEUUID(hexFour: String): UUID = UUID.fromString("0000$hexFour-0000-1000
*
* This class fixes the API by using coroutines to let you safely do a series of BTLE operations.
*/
class SafeBluetooth(private val context: Context, private val device: BluetoothDevice) : Closeable {
class SafeBluetooth(
private val context: Context,
private val device: BluetoothDevice,
private val analytics: PlatformAnalytics,
) : Closeable {
// / Timeout before we declare a bluetooth operation failed (used for synchronous API operations only)
var timeoutMsec = 20 * 1000L
@ -430,7 +434,7 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD
try {
it.completion.resumeWithException(ex)
} catch (ex: Exception) {
Timber.e("Mystery exception, why were we informed about our own exceptions?", ex)
Timber.e(ex, "Mystery exception, why were we informed about our own exceptions?")
}
}
workQueue.clear()

View file

@ -75,7 +75,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshUtilApplication.Companion.analytics
import com.geeksville.mesh.MeshUtilApplication
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.channelsGraph
@ -83,7 +83,6 @@ import com.geeksville.mesh.navigation.connectionsGraph
import com.geeksville.mesh.navigation.contactsGraph
import com.geeksville.mesh.navigation.mapGraph
import com.geeksville.mesh.navigation.nodesGraph
import com.geeksville.mesh.navigation.settingsGraph
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
@ -114,6 +113,7 @@ import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.icon.Settings
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.feature.settings.navigation.settingsGraph
import timber.log.Timber
enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) {
@ -158,7 +158,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}
}
analytics.addNavigationTrackingEffect(navController = navController)
MeshUtilApplication.analytics.addNavigationTrackingEffect(navController = navController)
VersionChecks(uIViewModel)
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle()

View file

@ -55,9 +55,9 @@ import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig.ModemPreset
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.components.ChannelSelection
import org.meshtastic.core.model.Channel
import org.meshtastic.core.strings.R
import org.meshtastic.feature.settings.radio.component.ChannelSelection
@Composable
fun ScannedQrCodeDialog(

View file

@ -26,12 +26,12 @@ import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.config
import com.geeksville.mesh.model.getChannelList
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.proto.getChannelList
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import javax.inject.Inject

View file

@ -55,15 +55,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.delay
import org.meshtastic.core.navigation.Route
@ -73,6 +69,10 @@ import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")

View file

@ -1,748 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.debug
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.twotone.FilterAltOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.core.IOException
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.DebugViewModel
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.theme.AnnotationColor
import org.meshtastic.core.ui.theme.AppTheme
import timber.log.Timber
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
// list of dict keys to redact when exporting logs. These are evaluated as line.contains, so partials are fine.
private var redactedKeys: List<String> = listOf("session_passkey", "private_key", "admin_key")
@Suppress("LongMethod")
@Composable
internal fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewModel()) {
val listState = rememberLazyListState()
val logs by viewModel.meshLog.collectAsStateWithLifecycle()
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val filterTexts by viewModel.filterTexts.collectAsStateWithLifecycle()
val selectedLogId by viewModel.selectedLogId.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var filterMode by remember { mutableStateOf(FilterMode.OR) }
val filteredLogsState by
remember(logs, filterTexts, filterMode) {
derivedStateOf { viewModel.filterManager.filterLogs(logs, filterTexts, filterMode).toImmutableList() }
}
val filteredLogs = filteredLogsState
LaunchedEffect(filteredLogs) { viewModel.updateFilteredLogs(filteredLogs) }
val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } }
if (shouldAutoScroll) {
LaunchedEffect(filteredLogs) {
if (!listState.isScrollInProgress) {
listState.animateScrollToItem(0)
}
}
}
// Handle search result navigation
LaunchedEffect(searchState) {
if (searchState.currentMatchIndex >= 0 && searchState.currentMatchIndex < searchState.allMatches.size) {
listState.requestScrollToItem(searchState.allMatches[searchState.currentMatchIndex].logIndex)
}
}
// Prepare a document creator for exporting logs via SAF
val exportLogsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { createdUri ->
if (createdUri != null) {
scope.launch { exportAllLogsToUri(context, createdUri, filteredLogs) }
}
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.debug_panel),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = { DebugMenuActions(deleteLogs = { viewModel.deleteAllLogs() }) },
onClickChip = {},
)
},
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
stickyHeader {
val animatedAlpha by
animateFloatAsState(
targetValue = if (!listState.isScrollInProgress) 1.0f else 0f,
label = "alpha",
)
DebugSearchStateviewModelDefaults(
modifier = Modifier.graphicsLayer(alpha = animatedAlpha),
searchState = searchState,
filterTexts = filterTexts,
presetFilters = viewModel.presetFilters,
logs = logs,
filterMode = filterMode,
onFilterModeChange = { filterMode = it },
onExportLogs = {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val fileName = "meshtastic_debug_$timestamp.txt"
exportLogsLauncher.launch(fileName)
},
)
}
items(filteredLogs, key = { it.uuid }) { log ->
DebugItem(
modifier = Modifier.animateItem(),
log = log,
searchText = searchState.searchText,
isSelected = selectedLogId == log.uuid,
onLogClick = { viewModel.setSelectedLogId(if (selectedLogId == log.uuid) null else log.uuid) },
)
}
}
}
}
}
@Composable
internal fun DebugItem(
log: UiMeshLog,
modifier: Modifier = Modifier,
searchText: String = "",
isSelected: Boolean = false,
onLogClick: () -> Unit = {},
) {
val colorScheme = MaterialTheme.colorScheme
Card(
modifier = modifier.fillMaxWidth().padding(4.dp),
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
colorScheme.primary.copy(alpha = 0.1f)
} else {
colorScheme.surface
},
),
border =
if (isSelected) {
BorderStroke(2.dp, colorScheme.primary)
} else {
null
},
) {
SelectionContainer {
Column(
modifier = Modifier.padding(if (isSelected) 12.dp else 8.dp).fillMaxWidth().clickable { onLogClick() },
) {
DebugItemHeader(log = log, searchText = searchText, isSelected = isSelected, theme = colorScheme)
val messageAnnotatedString = rememberAnnotatedLogMessage(log, searchText)
Text(
text = messageAnnotatedString,
softWrap = false,
style =
TextStyle(
fontSize = if (isSelected) 12.sp else 9.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface,
),
)
// Show decoded payload if available, with search highlighting
if (!log.decodedPayload.isNullOrBlank()) {
DecodedPayloadBlock(
decodedPayload = log.decodedPayload,
isSelected = isSelected,
colorScheme = colorScheme,
searchText = searchText,
modifier = Modifier,
)
}
}
}
}
}
@Composable
private fun DebugItemHeader(log: UiMeshLog, searchText: String, isSelected: Boolean, theme: ColorScheme) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = if (isSelected) 12.dp else 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val typeAnnotatedString = rememberAnnotatedString(text = log.messageType, searchText = searchText)
Text(
text = typeAnnotatedString,
modifier = Modifier.weight(1f),
style =
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = if (isSelected) 16.sp else 14.sp,
color = theme.onSurface,
),
)
// Copy full log: message + decoded payload if present
val fullLogText =
remember(log.logMessage, log.decodedPayload) {
buildString {
append(log.logMessage)
if (!log.decodedPayload.isNullOrBlank()) {
append("\n\nDecoded Payload:\n{")
append("\n")
append(log.decodedPayload)
append("\n}")
}
}
}
CopyIconButton(valueToCopy = fullLogText, modifier = Modifier.padding(start = 8.dp))
val dateAnnotatedString = rememberAnnotatedString(text = log.formattedReceivedDate, searchText = searchText)
Text(
text = dateAnnotatedString,
style =
TextStyle(
fontWeight = FontWeight.Bold,
fontSize = if (isSelected) 14.sp else 12.sp,
color = theme.onSurface,
),
)
}
}
@Composable
private fun rememberAnnotatedString(text: String, searchText: String): AnnotatedString {
val theme = MaterialTheme.colorScheme
val highlightStyle = SpanStyle(background = theme.primary.copy(alpha = 0.3f), color = theme.onSurface)
return remember(text, searchText) {
buildAnnotatedString {
append(text)
if (searchText.isNotEmpty()) {
searchText.split(" ").forEach { term ->
Regex(Regex.escape(term), RegexOption.IGNORE_CASE).findAll(text).forEach { match ->
addStyle(style = highlightStyle, start = match.range.first, end = match.range.last + 1)
}
}
}
}
}
}
@Composable
private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): AnnotatedString {
val theme = MaterialTheme.colorScheme
val style = SpanStyle(color = AnnotationColor, fontStyle = FontStyle.Italic)
val highlightStyle = SpanStyle(background = theme.primary.copy(alpha = 0.3f), color = theme.onSurface)
return remember(log.uuid, searchText) {
buildAnnotatedString {
append(log.logMessage)
// Add node ID annotations
REGEX_ANNOTATED_NODE_ID.findAll(log.logMessage).toList().reversed().forEach {
addStyle(style = style, start = it.range.first, end = it.range.last + 1)
}
// Add search highlight annotations
if (searchText.isNotEmpty()) {
searchText.split(" ").forEach { term ->
Regex(Regex.escape(term), RegexOption.IGNORE_CASE).findAll(log.logMessage).forEach { match ->
addStyle(style = highlightStyle, start = match.range.first, end = match.range.last + 1)
}
}
}
}
}
}
@Composable
fun DebugMenuActions(deleteLogs: () -> Unit, modifier: Modifier = Modifier) {
var showDeleteLogsDialog by remember { mutableStateOf(false) }
IconButton(onClick = { showDeleteLogsDialog = true }, modifier = modifier.padding(4.dp)) {
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.debug_clear))
}
if (showDeleteLogsDialog) {
SimpleAlertDialog(
title = R.string.debug_clear,
text = R.string.debug_clear_logs_confirm,
onConfirm = {
showDeleteLogsDialog = false
deleteLogs()
},
onDismiss = { showDeleteLogsDialog = false },
)
}
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<UiMeshLog>) =
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(targetUri)?.use { os ->
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
if (!log.decodedPayload.isNullOrBlank()) {
writer.write("\n\nDecoded Payload:\n{")
writer.write("\n")
// Redact Decoded keys.
log.decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.substring(0, idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("\n}")
}
writer.write("\n\n")
}
}
} ?: run { throw IOException("Unable to open output stream for URI: $targetUri") }
withContext(Dispatchers.Main) {
Toast.makeText(context, context.getString(R.string.debug_export_success, logs.size), Toast.LENGTH_LONG)
.show()
}
} catch (e: IOException) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.debug_export_failed, e.message ?: ""),
Toast.LENGTH_LONG,
)
.show()
}
Timber.w(e, "Error:IOException ")
}
}
@Composable
private fun DecodedPayloadBlock(
decodedPayload: String,
isSelected: Boolean,
colorScheme: ColorScheme,
searchText: String = "",
modifier: Modifier = Modifier,
) {
val commonTextStyle =
TextStyle(fontSize = if (isSelected) 10.sp else 8.sp, fontWeight = FontWeight.Bold, color = colorScheme.primary)
Column(modifier = modifier) {
Text(
text = stringResource(id = R.string.debug_decoded_payload),
style = commonTextStyle,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
Text(text = "{", style = commonTextStyle, modifier = Modifier.padding(start = 8.dp, bottom = 2.dp))
val annotatedPayload = rememberAnnotatedDecodedPayload(decodedPayload, searchText, colorScheme)
Text(
text = annotatedPayload,
softWrap = true,
style =
TextStyle(
fontSize = if (isSelected) 10.sp else 8.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f),
),
modifier = Modifier.padding(start = 16.dp, bottom = 0.dp),
)
Text(text = "}", style = commonTextStyle, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp))
}
}
@Composable
private fun rememberAnnotatedDecodedPayload(
decodedPayload: String,
searchText: String,
colorScheme: ColorScheme,
): AnnotatedString {
val highlightStyle = SpanStyle(background = colorScheme.primary.copy(alpha = 0.3f), color = colorScheme.onSurface)
return remember(decodedPayload, searchText) {
buildAnnotatedString {
append(decodedPayload)
if (searchText.isNotEmpty()) {
searchText.split(" ").forEach { term ->
Regex(Regex.escape(term), RegexOption.IGNORE_CASE).findAll(decodedPayload).forEach { match ->
addStyle(style = highlightStyle, start = match.range.first, end = match.range.last + 1)
}
}
}
}
}
}
@PreviewLightDark
@Composable
private fun DebugPacketPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "",
messageType = "NodeInfo",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage =
"from: 2885173132\n" +
"decoded {\n" +
" position {\n" +
" altitude: 60\n" +
" battery_level: 81\n" +
" latitude_i: 411111136\n" +
" longitude_i: -711111805\n" +
" time: 1600390966\n" +
" }\n" +
"}\n" +
"hop_limit: 3\n" +
"id: 1737414295\n" +
"rx_snr: 9.5\n" +
"rx_time: 316400569\n" +
"to: -1409790708",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemWithSearchHighlightPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "1",
messageType = "TextMessage",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage = "Hello world! This is a test message with some keywords to search for.",
),
searchText = "test message",
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemPositionPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "2",
messageType = "Position",
formattedReceivedDate = "9/27/20, 8:01:15 PM",
logMessage = "Position update from node (!a1b2c3d4) at coordinates 40.7128, -74.0060",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemErrorPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "3",
messageType = "Error",
formattedReceivedDate = "9/27/20, 8:02:30 PM",
logMessage =
"Connection failed: timeout after 30 seconds\n" +
"Retry attempt: 3/5\n" +
"Last known position: 40.7128, -74.0060",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemLongMessagePreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "4",
messageType = "Waypoint",
formattedReceivedDate = "9/27/20, 8:03:45 PM",
logMessage =
"Waypoint created:\n" +
" Name: Home Base\n" +
" Description: Primary meeting location\n" +
" Latitude: 40.7128\n" +
" Longitude: -74.0060\n" +
" Altitude: 100m\n" +
" Icon: 🏠\n" +
" Created by: (!a1b2c3d4)\n" +
" Expires: 2025-12-31 23:59:59",
),
)
}
}
@PreviewLightDark
@Composable
private fun DebugItemSelectedPreview() {
AppTheme {
DebugItem(
UiMeshLog(
uuid = "5",
messageType = "TextMessage",
formattedReceivedDate = "9/27/20, 8:04:20 PM",
logMessage = "This is a selected log item with larger font sizes for better readability.",
),
isSelected = true,
)
}
}
@PreviewLightDark
@Composable
private fun DebugMenuActionsPreview() {
AppTheme {
Row(modifier = Modifier.padding(16.dp)) {
IconButton(onClick = { /* Preview only */ }, modifier = Modifier.padding(4.dp)) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = stringResource(id = R.string.debug_logs_export),
)
}
IconButton(onClick = { /* Preview only */ }, modifier = Modifier.padding(4.dp)) {
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.debug_clear))
}
}
}
}
@PreviewLightDark
@Composable
@Suppress("detekt:LongMethod") // big preview
private fun DebugScreenEmptyPreview() {
AppTheme {
Surface {
LazyColumn(modifier = Modifier.fillMaxSize()) {
stickyHeader {
Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.weight(1f).padding(end = 8.dp),
placeholder = { Text("Search in logs...") },
singleLine = true,
)
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "Filters", style = TextStyle(fontWeight = FontWeight.Bold))
Icon(
imageVector = Icons.TwoTone.FilterAltOff,
contentDescription = stringResource(id = R.string.debug_filters),
)
}
}
}
}
}
}
}
// Empty state
item {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No Debug Logs",
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold),
)
Text(
text = "Debug logs will appear here when available",
style = TextStyle(fontSize = 14.sp, color = Color.Gray),
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
}
}
}
@PreviewLightDark
@Composable
@Suppress("detekt:LongMethod") // big preview
private fun DebugScreenWithSampleDataPreview() {
AppTheme {
val sampleLogs =
listOf(
UiMeshLog(
uuid = "1",
messageType = "NodeInfo",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage =
"from: 2885173132\n" +
"decoded {\n" +
" position {\n" +
" altitude: 60\n" +
" battery_level: 81\n" +
" latitude_i: 411111136\n" +
" longitude_i: -711111805\n" +
" time: 1600390966\n" +
" }\n" +
"}\n" +
"hop_limit: 3\n" +
"id: 1737414295\n" +
"rx_snr: 9.5\n" +
"rx_time: 316400569\n" +
"to: -1409790708",
),
UiMeshLog(
uuid = "2",
messageType = "TextMessage",
formattedReceivedDate = "9/27/20, 8:01:15 PM",
logMessage = "Hello from node (!a1b2c3d4)! How's the weather today?",
),
UiMeshLog(
uuid = "3",
messageType = "Position",
formattedReceivedDate = "9/27/20, 8:02:30 PM",
logMessage = "Position update: 40.7128, -74.0060, altitude: 100m, battery: 85%",
),
UiMeshLog(
uuid = "4",
messageType = "Waypoint",
formattedReceivedDate = "9/27/20, 8:03:45 PM",
logMessage = "New waypoint created: 'Meeting Point' at 40.7589, -73.9851",
),
UiMeshLog(
uuid = "5",
messageType = "Error",
formattedReceivedDate = "9/27/20, 8:04:20 PM",
logMessage = "Connection timeout - retrying in 5 seconds...",
),
)
// Note: This preview shows the UI structure but won't have actual data
// since the ViewModel isn't injected in previews
Surface {
LazyColumn(modifier = Modifier.fillMaxSize()) {
stickyHeader {
Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = "Debug Screen Preview",
style = TextStyle(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp),
)
Text(
text = "Search and filter controls would appear here",
style = TextStyle(fontSize = 12.sp, color = Color.Gray),
)
}
}
}
items(sampleLogs) { log -> DebugItem(log = log) }
}
}
}
}

View file

@ -1,285 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.twotone.FilterAlt
import androidx.compose.material.icons.twotone.FilterAltOff
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import org.meshtastic.core.strings.R
@Composable
fun DebugCustomFilterInput(
customFilterText: String,
onCustomFilterTextChange: (String) -> Unit,
filterTexts: List<String>,
onFilterTextsChange: (List<String>) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.fillMaxWidth().padding(bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = customFilterText,
onValueChange = onCustomFilterTextChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Add custom filter") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
if (customFilterText.isNotBlank()) {
onFilterTextsChange(filterTexts + customFilterText)
onCustomFilterTextChange("")
}
},
),
)
Spacer(modifier = Modifier.padding(horizontal = 8.dp))
IconButton(
onClick = {
if (customFilterText.isNotBlank()) {
onFilterTextsChange(filterTexts + customFilterText)
onCustomFilterTextChange("")
}
},
enabled = customFilterText.isNotBlank(),
) {
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = R.string.debug_filter_add))
}
}
}
@Composable
internal fun DebugPresetFilters(
presetFilters: List<String>,
filterTexts: List<String>,
logs: List<UiMeshLog>,
onFilterTextsChange: (List<String>) -> Unit,
modifier: Modifier = Modifier,
) {
val availableFilters =
presetFilters.filter { filter ->
logs.any { log ->
log.logMessage.contains(filter, ignoreCase = true) ||
log.messageType.contains(filter, ignoreCase = true) ||
log.formattedReceivedDate.contains(filter, ignoreCase = true)
}
}
Column(modifier = modifier) {
Text(
text = "Preset Filters",
style = TextStyle(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(vertical = 4.dp),
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
for (filter in availableFilters) {
FilterChip(
selected = filter in filterTexts,
onClick = {
onFilterTextsChange(
if (filter in filterTexts) {
filterTexts - filter
} else {
filterTexts + filter
},
)
},
label = { Text(filter) },
leadingIcon = {
if (filter in filterTexts) {
Icon(
imageVector = Icons.Filled.Done,
contentDescription = stringResource(id = R.string.debug_filter_included),
)
}
},
)
}
}
}
}
@Composable
internal fun DebugFilterBar(
filterTexts: List<String>,
onFilterTextsChange: (List<String>) -> Unit,
customFilterText: String,
onCustomFilterTextChange: (String) -> Unit,
presetFilters: List<String>,
logs: List<UiMeshLog>,
modifier: Modifier = Modifier,
) {
var showFilterMenu by remember { mutableStateOf(false) }
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Box {
TextButton(onClick = { showFilterMenu = !showFilterMenu }) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stringResource(R.string.debug_filters), style = TextStyle(fontWeight = FontWeight.Bold))
Icon(
imageVector =
if (filterTexts.isNotEmpty()) {
Icons.TwoTone.FilterAlt
} else {
Icons.TwoTone.FilterAltOff
},
contentDescription = stringResource(id = R.string.debug_filters),
)
}
}
DropdownMenu(
expanded = showFilterMenu,
onDismissRequest = { showFilterMenu = false },
offset = DpOffset(0.dp, 8.dp),
) {
Column(modifier = Modifier.padding(8.dp).width(300.dp)) {
DebugCustomFilterInput(
customFilterText = customFilterText,
onCustomFilterTextChange = onCustomFilterTextChange,
filterTexts = filterTexts,
onFilterTextsChange = onFilterTextsChange,
)
DebugPresetFilters(
presetFilters = presetFilters,
filterTexts = filterTexts,
logs = logs,
onFilterTextsChange = onFilterTextsChange,
)
}
}
}
}
}
@Suppress("LongMethod")
@Composable
internal fun DebugActiveFilters(
filterTexts: List<String>,
onFilterTextsChange: (List<String>) -> Unit,
filterMode: FilterMode,
onFilterModeChange: (FilterMode) -> Unit,
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
if (filterTexts.isNotEmpty()) {
Column(modifier = modifier) {
Row(
modifier =
Modifier.fillMaxWidth()
.padding(vertical = 2.dp)
.background(colorScheme.background.copy(alpha = 1.0f)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.debug_active_filters),
style = TextStyle(fontWeight = FontWeight.Bold),
)
TextButton(
onClick = {
onFilterModeChange(
if (filterMode == FilterMode.OR) {
FilterMode.AND
} else {
FilterMode.OR
},
)
},
) {
Text(
if (filterMode == FilterMode.OR) {
stringResource(R.string.match_any)
} else {
stringResource(R.string.match_all)
},
)
}
IconButton(onClick = { onFilterTextsChange(emptyList()) }) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(id = R.string.debug_filter_clear),
)
}
}
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 0.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
for (filter in filterTexts) {
FilterChip(
selected = true,
onClick = { onFilterTextsChange(filterTexts - filter) },
label = { Text(filter) },
leadingIcon = { Icon(imageVector = Icons.TwoTone.FilterAlt, contentDescription = null) },
trailingIcon = { Icon(imageVector = Icons.Filled.Clear, contentDescription = null) },
)
}
}
}
}
}
enum class FilterMode {
OR,
AND,
}

View file

@ -1,302 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.debug
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.filled.Clear
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.geeksville.mesh.model.DebugViewModel
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import com.geeksville.mesh.model.LogSearchManager.SearchMatch
import com.geeksville.mesh.model.LogSearchManager.SearchState
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.AppTheme
@Composable
internal fun DebugSearchNavigation(
searchState: SearchState,
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.width(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${searchState.currentMatchIndex + 1}/${searchState.allMatches.size}",
modifier = Modifier.padding(end = 4.dp),
style = TextStyle(fontSize = 12.sp),
)
IconButton(onClick = onPreviousMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = stringResource(R.string.debug_search_prev),
modifier = Modifier.size(16.dp),
)
}
IconButton(onClick = onNextMatch, enabled = searchState.hasMatches, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(R.string.debug_search_next),
modifier = Modifier.size(16.dp),
)
}
}
}
@Composable
internal fun DebugSearchBar(
searchState: SearchState,
onSearchTextChange: (String) -> Unit,
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
onClearSearch: () -> Unit,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = searchState.searchText,
onValueChange = onSearchTextChange,
modifier = modifier.then(Modifier.padding(end = 8.dp)),
placeholder = { Text(stringResource(R.string.debug_default_search)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions =
KeyboardActions(
onSearch = {
// Clear focus when search is performed
},
),
trailingIcon = {
Row(
modifier = Modifier.width(IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (searchState.hasMatches) {
DebugSearchNavigation(
searchState = searchState,
onNextMatch = onNextMatch,
onPreviousMatch = onPreviousMatch,
)
}
if (searchState.searchText.isNotEmpty()) {
IconButton(onClick = onClearSearch, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.debug_search_clear),
modifier = Modifier.size(16.dp),
)
}
}
}
},
)
}
@Composable
internal fun DebugSearchState(
modifier: Modifier = Modifier,
searchState: SearchState,
filterTexts: List<String>,
presetFilters: List<String>,
logs: List<UiMeshLog>,
onSearchTextChange: (String) -> Unit,
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
onClearSearch: () -> Unit,
onFilterTextsChange: (List<String>) -> Unit,
filterMode: FilterMode,
onFilterModeChange: (FilterMode) -> Unit,
onExportLogs: (() -> Unit)? = null,
) {
val colorScheme = MaterialTheme.colorScheme
var customFilterText by remember { mutableStateOf("") }
Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().background(colorScheme.background.copy(alpha = 1.0f)),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DebugSearchBar(
modifier = Modifier.weight(1f),
searchState = searchState,
onSearchTextChange = onSearchTextChange,
onNextMatch = onNextMatch,
onPreviousMatch = onPreviousMatch,
onClearSearch = onClearSearch,
)
DebugFilterBar(
filterTexts = filterTexts,
onFilterTextsChange = onFilterTextsChange,
customFilterText = customFilterText,
onCustomFilterTextChange = { customFilterText = it },
presetFilters = presetFilters,
logs = logs,
modifier = Modifier,
)
onExportLogs?.let { onExport ->
IconButton(onClick = onExport, modifier = Modifier) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = stringResource(id = R.string.debug_logs_export),
modifier = Modifier.size(24.dp),
)
}
}
}
}
DebugActiveFilters(
filterTexts = filterTexts,
onFilterTextsChange = onFilterTextsChange,
filterMode = filterMode,
onFilterModeChange = onFilterModeChange,
)
}
@Composable
fun DebugSearchStateviewModelDefaults(
modifier: Modifier = Modifier,
searchState: SearchState,
filterTexts: List<String>,
presetFilters: List<String>,
logs: List<UiMeshLog>,
filterMode: FilterMode,
onFilterModeChange: (FilterMode) -> Unit,
onExportLogs: (() -> Unit)? = null,
) {
val viewModel: DebugViewModel = hiltViewModel()
DebugSearchState(
modifier = modifier,
searchState = searchState,
filterTexts = filterTexts,
presetFilters = presetFilters,
logs = logs,
onSearchTextChange = viewModel.searchManager::setSearchText,
onNextMatch = viewModel.searchManager::goToNextMatch,
onPreviousMatch = viewModel.searchManager::goToPreviousMatch,
onClearSearch = viewModel.searchManager::clearSearch,
onFilterTextsChange = viewModel.filterManager::setFilterTexts,
filterMode = filterMode,
onFilterModeChange = onFilterModeChange,
onExportLogs = onExportLogs,
)
}
@PreviewLightDark
@Composable
private fun DebugSearchBarEmptyPreview() {
AppTheme {
Surface {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
DebugSearchBar(
searchState = SearchState(),
onSearchTextChange = {},
onNextMatch = {},
onPreviousMatch = {},
onClearSearch = {},
)
}
}
}
}
@PreviewLightDark
@Composable
@Suppress("detekt:MagicNumber") // fake data
private fun DebugSearchBarWithTextPreview() {
AppTheme {
Surface {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
DebugSearchBar(
searchState =
SearchState(
searchText = "test message",
currentMatchIndex = 2,
allMatches = List(5) { SearchMatch(it, 0, 10, "message") },
hasMatches = true,
),
onSearchTextChange = {},
onNextMatch = {},
onPreviousMatch = {},
onClearSearch = {},
)
}
}
}
}
@PreviewLightDark
@Composable
@Suppress("detekt:MagicNumber") // fake data
private fun DebugSearchBarWithMatchesPreview() {
AppTheme {
Surface {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
DebugSearchBar(
searchState =
SearchState(
searchText = "error",
currentMatchIndex = 0,
allMatches = List(3) { SearchMatch(it, 0, 5, "message") },
hasMatches = true,
),
onSearchTextChange = {},
onNextMatch = {},
onPreviousMatch = {},
onClearSearch = {},
)
}
}
}
}

View file

@ -1121,31 +1121,6 @@ private fun PowerMetrics(node: Node) {
}
}
@Composable
fun NodeActionButton(
modifier: Modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp),
title: String,
enabled: Boolean,
icon: ImageVector? = null,
iconTint: Color? = null,
onClick: () -> Unit,
) {
Button(onClick = { onClick() }, enabled = enabled, modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = title,
modifier = Modifier.size(24.dp),
tint = iconTint ?: LocalContentColor.current,
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {

View file

@ -1,456 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
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.filled.BugReport
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Output
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.settings.radio.RadioConfigItemList
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.EditDeviceProfileDialog
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
import com.geeksville.mesh.util.LanguageUtils
import com.geeksville.mesh.util.LanguageUtils.getLanguageMap
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.SettingsItemSwitch
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
settingsViewModel: SettingsViewModel = hiltViewModel(),
viewModel: RadioConfigViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {
PacketResponseStateDialog(
state = state.responseState,
onDismiss = {
isWaiting = false
viewModel.clearPacketResponse()
},
onComplete = {
getNavRouteFrom(state.route)?.let { route ->
isWaiting = false
viewModel.clearPacketResponse()
onNavigate(route)
}
},
)
}
var deviceProfile by remember { mutableStateOf<DeviceProfile?>(null) }
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
val importConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
showEditDeviceProfileDialog = true
it.data?.data?.let { uri -> viewModel.importProfile(uri) { profile -> deviceProfile = profile } }
}
}
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.exportProfile(uri, deviceProfile!!) }
}
}
if (showEditDeviceProfileDialog) {
EditDeviceProfileDialog(
title =
if (deviceProfile != null) {
stringResource(R.string.import_configuration)
} else {
stringResource(R.string.export_configuration)
},
deviceProfile = deviceProfile ?: viewModel.currentDeviceProfile,
onConfirm = {
showEditDeviceProfileDialog = false
if (deviceProfile != null) {
viewModel.installProfile(it)
} else {
deviceProfile = it
val nodeName = it.shortName.ifBlank { "node" }
val dateFormat = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.getDefault())
val dateStr = dateFormat.format(java.util.Date())
val fileName = "Meshtastic_${nodeName}_${dateStr}_nodeConfig.cfg"
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, fileName)
}
exportConfigLauncher.launch(intent)
}
},
onDismiss = {
showEditDeviceProfileDialog = false
deviceProfile = null
},
)
}
var showLanguagePickerDialog by remember { mutableStateOf(false) }
if (showLanguagePickerDialog) {
LanguagePickerDialog { showLanguagePickerDialog = false }
}
var showThemePickerDialog by remember { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
onDismiss = { showThemePickerDialog = false },
)
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(R.string.bottom_nav_settings),
subtitle =
if (state.isLocal) {
ourNode?.user?.longName
} else {
val remoteName = viewModel.destNode.value?.user?.longName ?: ""
stringResource(R.string.remotely_administrating, remoteName)
},
ourNode = ourNode,
showNodeChip = ourNode != null && isConnected && state.isLocal,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = { node -> onClickNodeChip(node.num) },
)
},
) { paddingValues ->
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security.isManaged,
excludedModulesUnlocked = excludedModulesUnlocked,
onRouteClick = { route ->
isWaiting = true
viewModel.setResponseStateLoading(route)
},
onImport = {
viewModel.clearPacketResponse()
deviceProfile = null
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
}
importConfigLauncher.launch(intent)
},
onExport = {
viewModel.clearPacketResponse()
deviceProfile = null
showEditDeviceProfileDialog = true
},
onNavigate = onNavigate,
)
val context = LocalContext.current
TitledCard(title = stringResource(R.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SettingsItemSwitch(
text = stringResource(R.string.analytics_okay),
checked = allowed,
leadingIcon = Icons.Default.BugReport,
onClick = { viewModel.toggleAnalyticsAllowed() },
)
}
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
settingsViewModel.meshService?.startProvideLocation()
} else {
Toast.makeText(
context,
context.getString(R.string.location_disabled),
Toast.LENGTH_LONG,
)
.show()
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
settingsViewModel.meshService?.stopProvideLocation()
}
}
SettingsItemSwitch(
text = stringResource(R.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
) {
settingsViewModel.setProvideLocation(!provideLocation)
}
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
// On Android 12 and below, system app settings for language are not available. Use the in-app language
// picker for these devices.
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
SettingsItem(
text = stringResource(R.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
if (useInAppLangPicker) {
showLanguagePickerDialog = true
} else {
val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
settingsLauncher.launch(intent)
} else {
// Fall back to the in-app picker
showLanguagePickerDialog = true
}
}
}
SettingsItem(
text = stringResource(R.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
showThemePickerDialog = true
}
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
text = stringResource(R.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_$timestamp.csv")
}
exportRangeTestLauncher.launch(intent)
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
SettingsItem(
text = stringResource(R.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_$timestamp.csv")
}
exportDataLauncher.launch(intent)
}
SettingsItem(
text = stringResource(R.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
settingsViewModel.showAppIntro()
}
SettingsItem(
text = stringResource(R.string.system_settings),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
settingsLauncher.launch(intent)
}
AppVersionButton(excludedModulesUnlocked) { settingsViewModel.unlockExcludedModules() }
}
}
}
}
private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */
@Composable
private fun AppVersionButton(excludedModulesUnlocked: Boolean, onUnlockExcludedModules: () -> Unit) {
val context = LocalContext.current
var clickCount by remember { mutableIntStateOf(0) }
LaunchedEffect(clickCount) {
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
clickCount = 0
}
}
SettingsItemDetail(
text = stringResource(R.string.app_version),
icon = Icons.Rounded.Memory,
trailingText = BuildConfig.VERSION_NAME,
) {
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
when {
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
clickCount = 0
Toast.makeText(context, context.getString(R.string.modules_already_unlocked), Toast.LENGTH_LONG).show()
}
clickCount == UNLOCK_CLICK_COUNT -> {
clickCount = 0
onUnlockExcludedModules()
Toast.makeText(context, context.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show()
}
}
}
}
@Composable
private fun LanguagePickerDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val choices = remember {
context
.getLanguageMap()
.map { (languageTag, languageName) -> languageName to { LanguageUtils.setAppLocale(languageTag) } }
.toMap()
}
MultipleChoiceAlertDialog(
title = stringResource(R.string.preferences_language),
message = "",
choices = choices,
onDismissRequest = onDismiss,
)
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
val context = LocalContext.current
val themeMap = remember {
mapOf(
context.getString(R.string.dynamic) to MODE_DYNAMIC,
context.getString(R.string.theme_light) to AppCompatDelegate.MODE_NIGHT_NO,
context.getString(R.string.theme_dark) to AppCompatDelegate.MODE_NIGHT_YES,
context.getString(R.string.theme_system) to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
)
.mapValues { (_, value) -> { onClickTheme(value) } }
}
MultipleChoiceAlertDialog(
title = stringResource(R.string.choose_theme),
message = "",
choices = themeMap,
onDismissRequest = onDismiss,
)
}

View file

@ -1,260 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings
import android.app.Application
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.Portnums
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
@Suppress("LongParameterList")
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val app: Application,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
private val uiPreferencesDataSource: UiPreferencesDataSource,
) : ViewModel() {
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val isConnected =
serviceRepository.connectionState
.map { it.isConnected() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false)
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000L),
LocalConfig.getDefaultInstance(),
)
val meshService: IMeshService?
get() = serviceRepository.meshService
val provideLocation: StateFlow<Boolean> =
myNodeInfo
.flatMapLatest { myNodeEntity ->
// When myNodeInfo changes, set up emissions for the "provide-location-nodeNum" pref.
if (myNodeEntity == null) {
flowOf(false)
} else {
uiPrefs.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
}
fun setTheme(theme: Int) {
uiPreferencesDataSource.setTheme(theme)
}
fun showAppIntro() {
uiPreferencesDataSource.setAppIntroCompleted(false)
}
fun unlockExcludedModules() {
_excludedModulesUnlocked.update { true }
}
/**
* Export all persisted packet data to a CSV file at the given URI.
*
* The CSV will include all packets, or only those matching the given port number if specified. Each row contains:
* date, time, sender node number, sender name, sender latitude, sender longitude, receiver latitude, receiver
* longitude, receiver elevation, received SNR, distance, hop limit, and payload.
*
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance)
// in the file_uri
val myNodeNum = myNodeNum ?: return@launch
// Capture the current node value while we're still on main thread
val nodes = nodeRepository.nodeDBbyNum.value
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }.takeIf { it?.isValid() == true }
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance\",\"hop limit\",\"payload\"",
)
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
// If we get a NodeInfo packet, use it to update our position data (if valid)
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
}
packet.meshPacket?.let { proto ->
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[
proto.from.takeIf { it != 0 } ?: myNodeNum,
] = position
}
}
// packets must have rxSNR, and optionally match the filter given as a param.
if (
(filterPortnum == null || proto.decoded.portnumValue == filterPortnum) &&
proto.rxSnr != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.longName ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rxSnr
// Calculate the distance if both positions are valid
val dist =
if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
.toString()
}
val hopLimit = proto.hopLimit
val payload =
when {
proto.decoded.portnumValue !in
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.RANGE_TEST_APP_VALUE,
) -> "<${proto.decoded.portnum}>"
proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"")
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
// elevation,rx
// snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
)
}
}
}
}
}
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
}
}
} catch (ex: FileNotFoundException) {
Timber.e("Can't write file error: ${ex.message}")
}
}
}
}

View file

@ -1,197 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.NodeChip
/**
* Composable screen for cleaning the node database. Allows users to specify criteria for deleting nodes. The list of
* nodes to be deleted updates automatically as filter criteria change.
*/
@Composable
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) {
val olderThanDays by viewModel.olderThanDays.collectAsState()
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
val nodesToDelete by viewModel.nodesToDelete.collectAsState()
var showConfirmationDialog by remember { mutableStateOf(false) }
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
if (showConfirmationDialog) {
ConfirmationDialog(
nodesToDeleteCount = nodesToDelete.size,
onConfirm = {
viewModel.cleanNodes()
showConfirmationDialog = false
},
onDismiss = { showConfirmationDialog = false },
)
}
Column(modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState())) {
Text(stringResource(R.string.clean_node_database_title))
Text(stringResource(R.string.clean_node_database_description), style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(16.dp))
DaysThresholdFilter(
olderThanDays = olderThanDays,
onlyUnknownNodes = onlyUnknownNodes,
onDaysChanged = viewModel::onOlderThanDaysChanged,
)
Spacer(modifier = Modifier.height(8.dp))
UnknownNodesFilter(onlyUnknownNodes = onlyUnknownNodes, onCheckedChanged = viewModel::onOnlyUnknownNodesChanged)
Spacer(modifier = Modifier.height(32.dp))
NodesDeletionPreview(nodesToDelete = nodesToDelete)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { if (nodesToDelete.isNotEmpty()) showConfirmationDialog = true },
modifier = Modifier.fillMaxWidth(),
enabled = nodesToDelete.isNotEmpty(),
) {
Text(stringResource(R.string.clean_now))
}
}
}
private const val MIN_UNKNOWN_DAYS_THRESHOLD = 0f
private const val MIN_KNOWN_DAYS_THRESHOLD = 7f
private const val MAX_DAYS_THRESHOLD = 365f
/**
* Composable for the "older than X days" filter. This filter is always active.
*
* @param olderThanDays The number of days for the filter.
* @param onlyUnknownNodes Whether the "only unknown nodes" filter is enabled.
* @param onDaysChanged Callback for when the number of days changes.
*/
@Composable
private fun DaysThresholdFilter(olderThanDays: Float, onlyUnknownNodes: Boolean, onDaysChanged: (Float) -> Unit) {
val valueRange =
if (onlyUnknownNodes) {
MIN_UNKNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD
} else {
MIN_KNOWN_DAYS_THRESHOLD..MAX_DAYS_THRESHOLD
}
val steps = (valueRange.endInclusive - valueRange.start - 1).toInt().coerceAtLeast(0)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
modifier = Modifier.padding(bottom = 8.dp),
text = stringResource(R.string.clean_nodes_older_than, olderThanDays.toInt()),
)
Slider(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
value = olderThanDays,
onValueChange = onDaysChanged,
valueRange = valueRange,
steps = steps,
)
}
}
/**
* Composable for the "only unknown nodes" filter.
*
* @param onlyUnknownNodes Whether the filter is enabled.
* @param onCheckedChanged Callback for when the checked state changes.
*/
@Composable
private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boolean) -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.clean_unknown_nodes))
Spacer(Modifier.weight(1f))
Switch(checked = onlyUnknownNodes, onCheckedChange = onCheckedChanged)
}
}
/**
* Composable for displaying the list of nodes queued for deletion.
*
* @param nodesToDelete The list of nodes to be deleted.
*/
@Composable
private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
Text(
stringResource(R.string.nodes_queued_for_deletion, nodesToDelete.size),
modifier = Modifier.padding(bottom = 16.dp),
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
) {
nodesToDelete.forEach { node ->
NodeChip(node = node.toModel(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp))
}
}
}
/**
* Composable for the confirmation dialog before deleting nodes.
*
* @param nodesToDeleteCount The number of nodes to be deleted.
* @param onConfirm Callback for when the user confirms the deletion.
* @param onDismiss Callback for when the user dismisses the dialog.
*/
@Composable
private fun ConfirmationDialog(nodesToDeleteCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.are_you_sure)) },
text = { Text(stringResource(R.string.clean_node_database_confirmation, nodesToDeleteCount)) },
confirmButton = { Button(onClick = onConfirm) { Text(stringResource(R.string.clean_now)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}

View file

@ -1,124 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
private const val MIN_DAYS_THRESHOLD = 7f
/**
* ViewModel for [CleanNodeDatabaseScreen]. Manages the state and logic for cleaning the node database based on
* specified criteria. The "older than X days" filter is always active.
*/
@HiltViewModel
class CleanNodeDatabaseViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
val olderThanDays = _olderThanDays.asStateFlow()
private val _onlyUnknownNodes = MutableStateFlow(false)
val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow()
private val _nodesToDelete = MutableStateFlow<List<NodeEntity>>(emptyList())
val nodesToDelete = _nodesToDelete.asStateFlow()
fun onOlderThanDaysChanged(value: Float) {
_olderThanDays.value = value
}
fun onOnlyUnknownNodesChanged(value: Boolean) {
_onlyUnknownNodes.value = value
if (!value && _olderThanDays.value < MIN_DAYS_THRESHOLD) {
_olderThanDays.value = MIN_DAYS_THRESHOLD
}
}
/**
* Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows:
* - The "older than X days" filter (controlled by the slider) is always active.
* - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected.
* - If "only unknown nodes" is not enabled, all nodes older than X days are selected.
* - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion.
* - Nodes marked as ignored or favorite are always excluded from deletion.
*/
fun getNodesToDelete() {
viewModelScope.launch {
val onlyUnknownEnabled = _onlyUnknownNodes.value
val currentTimeSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds
val initialNodesToConsider =
if (onlyUnknownEnabled) {
// Both "older than X days" and "only unknown nodes" filters apply
val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
val unknownNodes = nodeRepository.getUnknownNodes()
olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } }
} else {
// Only "older than X days" filter applies
nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
}
_nodesToDelete.value =
initialNodesToConsider.filterNot { node ->
// Exclude nodes with PKI heard in the last 7 days
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) ||
// Exclude ignored or favorite nodes
node.isIgnored ||
node.isFavorite
}
}
}
/**
* Deletes the nodes currently queued in [_nodesToDelete] from the database and instructs the mesh service to remove
* them.
*/
fun cleanNodes() {
viewModelScope.launch {
val nodeNums = _nodesToDelete.value.map { it.num }
if (nodeNums.isNotEmpty()) {
nodeRepository.deleteNodes(nodeNums)
val service = serviceRepository.meshService
if (service != null) {
for (nodeNum in nodeNums) {
service.removeByNodenum(service.packetId, nodeNum)
}
}
}
// Clear the list after deletion or if it was empty
_nodesToDelete.value = emptyList()
}
}
}

View file

@ -1,212 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Restore
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.ModuleRoute
import com.geeksville.mesh.ui.settings.radio.components.WarningDialog
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RadioConfigItemList(
state: RadioConfigState,
isManaged: Boolean,
excludedModulesUnlocked: Boolean = false,
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) }
LaunchedEffect(excludedModulesUnlocked) {
if (excludedModulesUnlocked) {
modules = ModuleRoute.entries
} else {
modules = ModuleRoute.filterExcludedFrom(state.metadata)
}
}
Column {
TitledCard(title = stringResource(R.string.radio_configuration)) {
if (isManaged) {
ManagedMessage()
}
ConfigRoute.radioConfigRoutes.forEach {
SettingsItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) {
onRouteClick(it)
}
}
}
TitledCard(title = stringResource(R.string.device_configuration), modifier = Modifier.padding(top = 16.dp)) {
if (isManaged) {
ManagedMessage()
}
ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
SettingsItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) {
onRouteClick(it)
}
}
}
TitledCard(title = stringResource(R.string.module_settings), modifier = Modifier.padding(top = 16.dp)) {
if (isManaged) {
ManagedMessage()
}
modules.forEach {
SettingsItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) {
onRouteClick(it)
}
}
}
}
if (state.isLocal) {
TitledCard(title = stringResource(R.string.backup_restore), modifier = Modifier.padding(top = 16.dp)) {
if (isManaged) {
ManagedMessage()
}
SettingsItem(
text = stringResource(R.string.import_configuration),
leadingIcon = Icons.Default.Download,
enabled = enabled,
onClick = onImport,
)
SettingsItem(
text = stringResource(R.string.export_configuration),
leadingIcon = Icons.Default.Upload,
enabled = enabled,
onClick = onExport,
)
}
}
TitledCard(title = stringResource(R.string.administration), modifier = Modifier.padding(top = 16.dp)) {
AdminRoute.entries.forEach { route ->
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
WarningDialog(
title = "${stringResource(route.title)}?",
onDismiss = { showDialog = false },
onConfirm = { onRouteClick(route) },
)
}
SettingsItem(
enabled = enabled,
text = stringResource(route.title),
leadingIcon = route.icon,
trailingIcon = null,
) {
showDialog = true
}
}
}
TitledCard(title = stringResource(R.string.advanced_title), modifier = Modifier.padding(top = 16.dp)) {
if (isManaged) {
ManagedMessage()
}
SettingsItem(
text = stringResource(R.string.clean_node_database_title),
leadingIcon = Icons.Rounded.CleaningServices,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
)
SettingsItem(
text = stringResource(R.string.debug_panel),
leadingIcon = Icons.Rounded.BugReport,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
}
enum class AdminRoute(val icon: ImageVector, @StringRes val title: Int) {
REBOOT(Icons.Rounded.RestartAlt, R.string.reboot),
SHUTDOWN(Icons.Rounded.PowerSettingsNew, R.string.shutdown),
FACTORY_RESET(Icons.Rounded.Restore, R.string.factory_reset),
NODEDB_RESET(Icons.Rounded.Storage, R.string.nodedb_reset),
}
@Preview(showBackground = true)
@Composable
private fun RadioSettingsScreenPreview() = AppTheme {
RadioConfigItemList(
state = RadioConfigState(isLocal = true, connected = true),
isManaged = false,
onNavigate = { _ -> },
)
}
@Composable
private fun ManagedMessage() {
Text(
text = stringResource(R.string.message_device_managed),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.StatusRed,
)
}
@Preview(showBackground = true)
@Composable
private fun RadioSettingsScreenManagedPreview() = AppTheme {
RadioConfigItemList(
state = RadioConfigState(isLocal = true, connected = true),
isManaged = true,
onNavigate = { _ -> },
)
}

View file

@ -1,708 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio
import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
import android.os.RemoteException
import android.util.Base64
import androidx.annotation.RequiresPermission
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.ConfigProtos.Config.SecurityConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.ModuleConfigProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.config
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.getChannelList
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.ModuleRoute
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.util.UiText
import com.google.protobuf.MessageLite
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.R
import timber.log.Timber
import java.io.FileOutputStream
import javax.inject.Inject
/** Data class that represents the current RadioConfig state. */
data class RadioConfigState(
val isLocal: Boolean = false,
val connected: Boolean = false,
val route: String = "",
val metadata: MeshProtos.DeviceMetadata? = null,
val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
val channelList: List<ChannelProtos.ChannelSettings> = emptyList(),
val radioConfig: ConfigProtos.Config = config {},
val moduleConfig: ModuleConfigProtos.ModuleConfig = moduleConfig {},
val ringtone: String = "",
val cannedMessageMessages: String = "",
val responseState: ResponseState<Boolean> = ResponseState.Empty,
val analyticsAvailable: Boolean = true,
val analyticsEnabled: Boolean = false,
)
@Suppress("LongParameterList")
@HiltViewModel
class RadioConfigViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
private val app: Application,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val locationRepository: LocationRepository,
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
) : ViewModel() {
private val meshService: IMeshService?
get() = serviceRepository.meshService
var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
fun toggleAnalyticsAllowed() {
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
}
private val destNum = savedStateHandle.toRoute<SettingsRoutes.Settings>().destNum
private val _destNode = MutableStateFlow<Node?>(null)
val destNode: StateFlow<Node?>
get() = _destNode
private val requestIds = MutableStateFlow(hashSetOf<Int>())
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
private val _currentDeviceProfile = MutableStateFlow(deviceProfile {})
val currentDeviceProfile
get() = _currentDeviceProfile.value
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
suspend fun getCurrentLocation(): Location? = if (
ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
) {
locationRepository.getLocations().firstOrNull()
} else {
null
}
init {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] ?: nodes.values.firstOrNull() }
.distinctUntilChanged()
.onEach {
_destNode.value = it
_radioConfigState.update { state -> state.copy(metadata = it?.metadata) }
}
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow.onEach { _currentDeviceProfile.value = it }.launchIn(viewModelScope)
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
combine(serviceRepository.connectionState, radioConfigState) { connState, configState ->
_radioConfigState.update { it.copy(connected = connState == ConnectionState.CONNECTED) }
}
.launchIn(viewModelScope)
nodeRepository.myNodeInfo
.onEach { ni ->
_radioConfigState.update { it.copy(isLocal = destNum == null || destNum == ni?.myNodeNum) }
}
.launchIn(viewModelScope)
Timber.d("RadioConfigViewModel created")
}
private val myNodeInfo: StateFlow<MyNodeEntity?>
get() = nodeRepository.myNodeInfo
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val maxChannels
get() = myNodeInfo.value?.maxChannels ?: 8
val hasPaFan: Boolean
get() =
destNode.value?.user?.hwModel in
setOf(
null,
MeshProtos.HardwareModel.UNRECOGNIZED,
MeshProtos.HardwareModel.UNSET,
MeshProtos.HardwareModel.BETAFPV_2400_TX,
MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT_NANO,
MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT,
)
override fun onCleared() {
super.onCleared()
Timber.d("RadioConfigViewModel cleared")
}
private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) =
viewModelScope.launch {
meshService?.let { service ->
val packetId = service.packetId
try {
requestAction(service, packetId, destNum)
requestIds.update { it.apply { add(packetId) } }
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val total = maxOf(requestIds.value.size, state.responseState.total)
state.copy(responseState = state.responseState.copy(total = total))
} else {
state.copy(
route = "", // setter (response is PortNum.ROUTING_APP)
responseState = ResponseState.Loading(),
)
}
}
} catch (ex: RemoteException) {
Timber.e("$errorMessage: ${ex.message}")
}
}
}
fun setOwner(user: MeshProtos.User) {
setRemoteOwner(destNode.value?.num ?: return, user)
}
private fun setRemoteOwner(destNum: Int, user: MeshProtos.User) = request(
destNum,
{ service, packetId, _ ->
_radioConfigState.update { it.copy(userConfig = user) }
service.setRemoteOwner(packetId, user.toByteArray())
},
"Request setOwner error",
)
private fun getOwner(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
"Request getOwner error",
)
fun updateChannels(new: List<ChannelProtos.ChannelSettings>, old: List<ChannelProtos.ChannelSettings>) {
val destNum = destNode.value?.num ?: return
getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
if (destNum == myNodeNum) viewModelScope.launch { radioConfigRepository.replaceAllSettings(new) }
_radioConfigState.update { it.copy(channelList = new) }
}
private fun setChannels(channelUrl: String) = viewModelScope.launch {
val new = channelUrl.toUri().toChannelSet()
val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch
updateChannels(new.settingsList, old.settingsList)
}
private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) = request(
destNum,
{ service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.toByteArray()) },
"Request setRemoteChannel error",
)
private fun getChannel(destNum: Int, index: Int) = request(
destNum,
{ service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
"Request getChannel error",
)
fun setConfig(config: ConfigProtos.Config) {
setRemoteConfig(destNode.value?.num ?: return, config)
}
private fun setRemoteConfig(destNum: Int, config: ConfigProtos.Config) = request(
destNum,
{ service, packetId, dest ->
_radioConfigState.update { it.copy(radioConfig = config) }
service.setRemoteConfig(packetId, dest, config.toByteArray())
},
"Request setConfig error",
)
private fun getConfig(destNum: Int, configType: Int) = request(
destNum,
{ service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
"Request getConfig error",
)
fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
setModuleConfig(destNode.value?.num ?: return, config)
}
private fun setModuleConfig(destNum: Int, config: ModuleConfigProtos.ModuleConfig) = request(
destNum,
{ service, packetId, dest ->
_radioConfigState.update { it.copy(moduleConfig = config) }
service.setModuleConfig(packetId, dest, config.toByteArray())
},
"Request setConfig error",
)
private fun getModuleConfig(destNum: Int, configType: Int) = request(
destNum,
{ service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
"Request getModuleConfig error",
)
fun setRingtone(ringtone: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(ringtone = ringtone) }
meshService?.setRingtone(destNum, ringtone)
}
private fun getRingtone(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getRingtone(packetId, dest) },
"Request getRingtone error",
)
fun setCannedMessages(messages: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
meshService?.setCannedMessages(destNum, messages)
}
private fun getCannedMessages(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getCannedMessages(packetId, dest) },
"Request getCannedMessages error",
)
private fun requestShutdown(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.requestShutdown(packetId, dest) },
"Request shutdown error",
)
private fun requestReboot(destNum: Int) =
request(destNum, { service, packetId, dest -> service.requestReboot(packetId, dest) }, "Request reboot error")
private fun requestFactoryReset(destNum: Int) {
request(
destNum,
{ service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
"Request factory reset error",
)
if (destNum == myNodeNum) {
viewModelScope.launch { nodeRepository.clearNodeDB() }
}
}
private fun requestNodedbReset(destNum: Int) {
request(
destNum,
{ service, packetId, dest -> service.requestNodedbReset(packetId, dest) },
"Request NodeDB reset error",
)
if (destNum == myNodeNum) {
viewModelScope.launch { nodeRepository.clearNodeDB() }
}
}
private fun sendAdminRequest(destNum: Int) {
val route = radioConfigState.value.route
_radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP)
when (route) {
AdminRoute.REBOOT.name -> requestReboot(destNum)
AdminRoute.SHUTDOWN.name ->
with(radioConfigState.value) {
if (metadata != null && !metadata.canShutdown) {
sendError(R.string.cant_shutdown)
} else {
requestShutdown(destNum)
}
}
AdminRoute.FACTORY_RESET.name -> requestFactoryReset(destNum)
AdminRoute.NODEDB_RESET.name -> requestNodedbReset(destNum)
}
}
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
try {
meshService?.setFixedPosition(destNum, position)
} catch (ex: RemoteException) {
Timber.e("Set fixed position error: ${ex.message}")
}
}
fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0))
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
app.contentResolver.openInputStream(uri).use { inputStream ->
val bytes = inputStream?.readBytes()
val protobuf = DeviceProfile.parseFrom(bytes)
onResult(protobuf)
}
} catch (ex: Exception) {
Timber.e("Import DeviceProfile error: ${ex.message}")
sendError(ex.customMessage)
}
}
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
message.writeTo(outputStream)
}
}
setResponseStateSuccess()
} catch (ex: Exception) {
Timber.e("Can't write file error: ${ex.message}")
sendError(ex.customMessage)
}
}
fun exportSecurityConfig(uri: Uri, securityConfig: SecurityConfig) =
viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
private val indentSpaces = 4
private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: SecurityConfig) =
withContext(Dispatchers.IO) {
try {
val publicKeyBytes = securityConfig.publicKey.toByteArray()
val privateKeyBytes = securityConfig.privateKey.toByteArray()
// Convert byte arrays to Base64 strings for human readability in JSON
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
// Create a JSON object
val jsonObject =
JSONObject().apply {
put("timestamp", System.currentTimeMillis())
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}
// Convert JSON object to a string
val jsonString = jsonObject.toString(indentSpaces)
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
}
}
setResponseStateSuccess()
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
Timber.e(errorMessage)
sendError(ex.customMessage)
}
}
fun installProfile(protobuf: DeviceProfile) = with(protobuf) {
meshService?.beginEditSettings()
if (hasLongName() || hasShortName()) {
destNode.value?.user?.let {
val user =
MeshProtos.User.newBuilder()
.setId(it.id)
.setLongName(if (hasLongName()) longName else it.longName)
.setShortName(if (hasShortName()) shortName else it.shortName)
.setIsLicensed(it.isLicensed)
.build()
setOwner(user)
}
}
if (hasChannelUrl()) {
try {
setChannels(channelUrl)
} catch (ex: Exception) {
Timber.e(ex, "DeviceProfile channel import error")
sendError(ex.customMessage)
}
}
if (hasConfig()) {
val descriptor = ConfigProtos.Config.getDescriptor()
config.allFields.forEach { (field, value) ->
val newConfig =
ConfigProtos.Config.newBuilder().setField(descriptor.findFieldByName(field.name), value).build()
setConfig(newConfig)
}
}
if (hasFixedPosition()) {
setFixedPosition(Position(fixedPosition))
}
if (hasModuleConfig()) {
val descriptor = ModuleConfigProtos.ModuleConfig.getDescriptor()
moduleConfig.allFields.forEach { (field, value) ->
val newConfig =
ModuleConfigProtos.ModuleConfig.newBuilder()
.setField(descriptor.findFieldByName(field.name), value)
.build()
setModuleConfig(newConfig)
}
}
meshService?.commitEditSettings()
}
fun clearPacketResponse() {
requestIds.value = hashSetOf()
_radioConfigState.update { it.copy(responseState = ResponseState.Empty) }
}
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update {
RadioConfigState(
isLocal = it.isLocal,
connected = it.connected,
route = route.name,
metadata = it.metadata,
responseState = ResponseState.Loading(),
)
}
when (route) {
ConfigRoute.USER -> getOwner(destNum)
ConfigRoute.CHANNELS -> {
getChannel(destNum, 0)
getConfig(destNum, ConfigRoute.LORA.type)
// channel editor is synchronous, so we don't use requestIds as total
setResponseStateTotal(maxChannels + 1)
}
is AdminRoute -> {
getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE)
setResponseStateTotal(2)
}
is ConfigRoute -> {
if (route == ConfigRoute.LORA) {
getChannel(destNum, 0)
}
getConfig(destNum, route.type)
}
is ModuleRoute -> {
if (route == ModuleRoute.CANNED_MESSAGE) {
getCannedMessages(destNum)
}
if (route == ModuleRoute.EXT_NOTIFICATION) {
getRingtone(destNum)
}
getModuleConfig(destNum, route.type)
}
}
}
fun shouldReportLocation(nodeNum: Int?) = mapConsentPrefs.shouldReportLocation(nodeNum)
fun setShouldReportLocation(nodeNum: Int?, shouldReportLocation: Boolean) {
mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation)
}
private fun setResponseStateTotal(total: Int) {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
state.copy(responseState = state.responseState.copy(total = total))
} else {
state // Return the unchanged state for other response states
}
}
}
private fun setResponseStateSuccess() {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
state.copy(responseState = ResponseState.Success(true))
} else {
state // Return the unchanged state for other response states
}
}
}
private val Exception.customMessage: String
get() = "${javaClass.simpleName}: $message"
private fun sendError(error: String) = setResponseStateError(UiText.DynamicString(error))
private fun sendError(@StringRes id: Int) = setResponseStateError(UiText.StringResource(id))
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
private fun incrementCompleted() {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val increment = state.responseState.completed + 1
state.copy(responseState = state.responseState.copy(completed = increment))
} else {
state // Return the unchanged state for other response states
}
}
}
private fun processPacketResponse(packet: MeshProtos.MeshPacket) {
val data = packet.decoded
if (data.requestId !in requestIds.value) return
val route = radioConfigState.value.route
val destNum = destNode.value?.num ?: return
val debugMsg = "requestId: ${data.requestId.toUInt()} to: ${destNum.toUInt()} received %s"
if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) {
val parsed = MeshProtos.Routing.parseFrom(data.payload)
Timber.d(debugMsg.format(parsed.errorReason.name))
if (parsed.errorReason != MeshProtos.Routing.Error.NONE) {
sendError(getStringResFrom(parsed.errorReasonValue))
} else if (packet.from == destNum && route.isEmpty()) {
requestIds.update { it.apply { remove(data.requestId) } }
if (requestIds.value.isEmpty()) {
setResponseStateSuccess()
} else {
incrementCompleted()
}
}
}
if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) {
val parsed = AdminProtos.AdminMessage.parseFrom(data.payload)
Timber.d(debugMsg.format(parsed.payloadVariantCase.name))
if (destNum != packet.from) {
sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
return
}
when (parsed.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> {
_radioConfigState.update { it.copy(metadata = parsed.getDeviceMetadataResponse) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
val response = parsed.getChannelResponse
// Stop once we get to the first disabled entry
if (response.role != ChannelProtos.Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
channelList =
state.channelList.toMutableList().apply { add(response.index, response.settings) },
)
}
incrementCompleted()
if (response.index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
getChannel(destNum, response.index + 1)
}
} else {
// Received last channel, update total and start channel editor
setResponseStateTotal(response.index + 1)
}
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> {
_radioConfigState.update { it.copy(userConfig = parsed.getOwnerResponse) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
val response = parsed.getConfigResponse
if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET
sendError(response.payloadVariantCase.name)
}
_radioConfigState.update { it.copy(radioConfig = response) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> {
val response = parsed.getModuleConfigResponse
if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET
sendError(response.payloadVariantCase.name)
}
_radioConfigState.update { it.copy(moduleConfig = response) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> {
_radioConfigState.update {
it.copy(cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse)
}
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> {
_radioConfigState.update { it.copy(ringtone = parsed.getRingtoneResponse) }
incrementCompleted()
}
else -> Timber.d("No custom processing needed for ${parsed.payloadVariantCase}")
}
if (AdminRoute.entries.any { it.name == route }) {
sendAdminRequest(destNum)
}
requestIds.update { it.apply { remove(data.requestId) } }
}
}
}

View file

@ -1,33 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio
import com.geeksville.mesh.util.UiText
/** Generic sealed class defines each possible state of a response. */
sealed class ResponseState<out T> {
data object Empty : ResponseState<Nothing>()
data class Loading(var total: Int = 1, var completed: Int = 0) : ResponseState<Nothing>()
data class Success<T>(val result: T) : ResponseState<T>()
data class Error(val error: UiText) : ResponseState<Nothing>()
fun isWaiting() = this !is Empty
}

View file

@ -1,108 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun AmbientLightingConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val ambientLightingConfig = state.moduleConfig.ambientLighting
val formState = rememberConfigState(initialValue = ambientLightingConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.ambient_lighting),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { ambientLighting = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.ambient_lighting_config)) }
item {
SwitchPreference(
title = stringResource(R.string.led_state),
checked = formState.value.ledState,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ledState = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.current),
value = formState.value.current,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { current = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.red),
value = formState.value.red,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { red = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.green),
value = formState.value.green,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { green = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.blue),
value = formState.value.blue,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { blue = it } },
)
}
}
}

View file

@ -1,134 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.AudioConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun AudioConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val audioConfig = state.moduleConfig.audio
val formState = rememberConfigState(initialValue = audioConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.audio),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { audio = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.audio_config)) }
item {
SwitchPreference(
title = stringResource(R.string.codec_2_enabled),
checked = formState.value.codec2Enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { codec2Enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.ptt_pin),
value = formState.value.pttPin,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { pttPin = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.codec2_sample_rate),
enabled = state.connected,
items =
AudioConfig.Audio_Baud.entries
.filter { it != AudioConfig.Audio_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.bitrate,
onItemSelected = { formState.value = formState.value.copy { bitrate = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.i2s_word_select),
value = formState.value.i2SWs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SWs = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.i2s_data_in),
value = formState.value.i2SSd,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SSd = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.i2s_data_out),
value = formState.value.i2SDin,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SDin = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.i2s_clock),
value = formState.value.i2SSck,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SSck = it } },
)
}
}
}

View file

@ -1,98 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ConfigProtos.Config.BluetoothConfig
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun BluetoothConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val bluetoothConfig = state.radioConfig.bluetooth
val formState = rememberConfigState(initialValue = bluetoothConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.bluetooth),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { bluetooth = it }
viewModel.setConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.bluetooth_config)) }
item {
SwitchPreference(
title = stringResource(R.string.bluetooth_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.pairing_mode),
enabled = state.connected,
items =
BluetoothConfig.PairingMode.entries
.filter { it != BluetoothConfig.PairingMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.mode,
onItemSelected = { formState.value = formState.value.copy { mode = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.fixed_pin),
value = formState.value.fixedPin,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
if (it.toString().length == 6) { // ensure 6 digits
formState.value = formState.value.copy { fixedPin = it }
}
},
)
}
}
}

View file

@ -1,213 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
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.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.CannedMessageConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun CannedMessageConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val cannedMessageConfig = state.moduleConfig.cannedMessage
val messages = state.cannedMessageMessages
val formState = rememberConfigState(initialValue = cannedMessageConfig)
var messagesInput by rememberSaveable(messages) { mutableStateOf(messages) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.canned_message),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
if (messagesInput != messages) {
viewModel.setCannedMessages(messagesInput)
}
if (formState.value != cannedMessageConfig) {
val config = moduleConfig { cannedMessage = formState.value }
viewModel.setModuleConfig(config)
}
},
) {
item { PreferenceCategory(text = stringResource(R.string.canned_message_config)) }
item {
SwitchPreference(
title = stringResource(R.string.canned_message_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.rotary_encoder_1_enabled),
checked = formState.value.rotary1Enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { rotary1Enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.gpio_pin_for_rotary_encoder_a_port),
value = formState.value.inputbrokerPinA,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { inputbrokerPinA = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.gpio_pin_for_rotary_encoder_b_port),
value = formState.value.inputbrokerPinB,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { inputbrokerPinB = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.gpio_pin_for_rotary_encoder_press_port),
value = formState.value.inputbrokerPinPress,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { inputbrokerPinPress = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.generate_input_event_on_press),
enabled = state.connected,
items =
CannedMessageConfig.InputEventChar.entries
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.inputbrokerEventPress,
onItemSelected = { formState.value = formState.value.copy { inputbrokerEventPress = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.generate_input_event_on_cw),
enabled = state.connected,
items =
CannedMessageConfig.InputEventChar.entries
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.inputbrokerEventCw,
onItemSelected = { formState.value = formState.value.copy { inputbrokerEventCw = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.generate_input_event_on_ccw),
enabled = state.connected,
items =
CannedMessageConfig.InputEventChar.entries
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.inputbrokerEventCcw,
onItemSelected = { formState.value = formState.value.copy { inputbrokerEventCcw = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.up_down_select_input_enabled),
checked = formState.value.updown1Enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { updown1Enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.allow_input_source),
value = formState.value.allowInputSource,
maxSize = 63, // allow_input_source max_size:16
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 { allowInputSource = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.send_bell),
checked = formState.value.sendBell,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { sendBell = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.messages),
value = messagesInput,
maxSize = 200, // messages max_size:201
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { messagesInput = it },
)
}
}
}

View file

@ -1,180 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R
/**
* At this firmware version periodic position sharing on a secondary channel was implemented. To enable this feature the
* user must disable position on the primary channel and enable on a secondary channel. The lowest indexed secondary
* channel with the position enabled will conduct the automatic position broadcasts.
*/
internal const val SECONDARY_CHANNEL_EPOCH = "2.6.10"
internal enum class ChannelIcons(
val icon: ImageVector,
@StringRes val descriptionResId: Int,
@StringRes val additionalInfoResId: Int,
) {
LOCATION(
icon = Icons.Filled.LocationOn,
descriptionResId = R.string.location_sharing,
additionalInfoResId = R.string.periodic_position_broadcast,
),
UPLINK(
icon = Icons.Filled.CloudUpload,
descriptionResId = R.string.uplink_enabled,
additionalInfoResId = R.string.uplink_feature_description,
),
DOWNLINK(
icon = Icons.Filled.CloudDownload,
descriptionResId = R.string.downlink_enabled,
additionalInfoResId = R.string.downlink_feature_description,
),
}
@Composable
internal fun ChannelLegend(onClick: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().clickable { onClick.invoke() },
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Row {
Icon(imageVector = Icons.Filled.Info, contentDescription = stringResource(R.string.info))
Text(
text = stringResource(R.string.primary),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp),
)
}
Text(
text = stringResource(R.string.secondary),
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 16.dp),
)
}
}
@Composable
internal fun ChannelLegendDialog(firmwareVersion: DeviceVersion, onDismiss: () -> Unit) {
AlertDialog(
modifier = Modifier.fillMaxSize(),
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.channel_features)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(R.string.primary),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "- ${stringResource(R.string.primary_channel_feature)}",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.secondary),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "- ${stringResource(R.string.secondary_no_telemetry)}",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text =
if (firmwareVersion >= DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) {
/* 2.6.10+ */
"- ${stringResource(R.string.secondary_channel_position_feature)}"
} else {
"- ${stringResource(R.string.manual_position_request)}"
},
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
IconDefinitions()
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.security_icon_help_dismiss)) }
}
},
)
}
@Composable
private fun IconDefinitions() {
Text(text = stringResource(R.string.icon_meanings), style = MaterialTheme.typography.titleLarge)
ChannelIcons.entries.forEach { icon ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon.icon, contentDescription = stringResource(icon.descriptionResId))
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(text = stringResource(icon.descriptionResId), style = MaterialTheme.typography.titleMedium)
Text(text = stringResource(icon.additionalInfoResId), style = MaterialTheme.typography.bodyMedium)
}
}
if (icon != ChannelIcons.entries.lastOrNull()) {
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}
}
}
@Preview
@Composable
private fun PreviewChannelLegendDialog() {
ChannelLegendDialog(firmwareVersion = DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) {}
}

View file

@ -1,416 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Add
import androidx.compose.material.icons.twotone.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ChannelProtos.ChannelSettings
import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig
import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.PreferenceFooter
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
@Composable
private fun ChannelItem(
index: Int,
title: String,
enabled: Boolean,
onClick: () -> Unit = {},
content: @Composable RowScope.() -> Unit,
) {
val fontColor = if (index == 0) MaterialTheme.colorScheme.primary else Color.Unspecified
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable(enabled = enabled) { onClick() }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp),
) {
AssistChip(onClick = onClick, label = { Text(text = "$index", color = fontColor) })
Text(
text = title,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
color = fontColor,
)
content()
}
}
}
@Composable
private fun ChannelCard(
index: Int,
title: String,
enabled: Boolean,
channelSettings: ChannelSettings,
loraConfig: LoRaConfig,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
sharesLocation: Boolean,
) = ChannelItem(index = index, title = title, enabled = enabled, onClick = onEditClick) {
if (sharesLocation) {
Icon(
imageVector = ChannelIcons.LOCATION.icon,
contentDescription = stringResource(ChannelIcons.LOCATION.descriptionResId),
modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp),
)
}
if (channelSettings.uplinkEnabled) {
Icon(
imageVector = ChannelIcons.UPLINK.icon,
contentDescription = stringResource(ChannelIcons.UPLINK.descriptionResId),
modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp),
)
}
if (channelSettings.downlinkEnabled) {
Icon(
imageVector = ChannelIcons.DOWNLINK.icon,
contentDescription = stringResource(ChannelIcons.DOWNLINK.descriptionResId),
modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp),
)
}
SecurityIcon(channelSettings, loraConfig)
Spacer(modifier = Modifier.width(10.dp))
IconButton(onClick = { onDeleteClick() }) {
Icon(
imageVector = Icons.TwoTone.Close,
contentDescription = stringResource(R.string.delete),
modifier = Modifier.wrapContentSize(),
)
}
}
@Composable
fun ChannelSelection(
index: Int,
title: String,
enabled: Boolean,
isSelected: Boolean,
onSelected: (Boolean) -> Unit,
channel: Channel,
) = ChannelItem(index = index, title = title, enabled = enabled) {
SecurityIcon(channel)
Spacer(modifier = Modifier.width(10.dp))
Checkbox(enabled = enabled, checked = isSelected, onCheckedChange = onSelected)
}
@Composable
fun ChannelConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
if (state.responseState.isWaiting()) {
PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
}
ChannelSettingsItemList(
title = stringResource(id = R.string.channels),
onBack = { navController.popBackStack() },
settingsList = state.channelList,
loraConfig = state.radioConfig.lora,
maxChannels = viewModel.maxChannels,
firmwareVersion = state.metadata?.firmwareVersion ?: "0.0.0",
enabled = state.connected,
onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun ChannelSettingsItemList(
title: String,
onBack: () -> Unit,
settingsList: List<ChannelSettings>,
loraConfig: LoRaConfig,
maxChannels: Int = 8,
firmwareVersion: String,
enabled: Boolean,
onPositiveClicked: (List<ChannelSettings>) -> Unit,
) {
val primarySettings = settingsList.getOrNull(0) ?: return
val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) }
val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) }
val fwVersion by
remember(firmwareVersion) { mutableStateOf(DeviceVersion(firmwareVersion.substringBeforeLast("."))) }
val focusManager = LocalFocusManager.current
val settingsListInput =
rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) {
settingsList.toMutableStateList()
}
val listState = rememberLazyListState()
val dragDropState =
rememberDragDropState(listState) { fromIndex, toIndex ->
if (toIndex in settingsListInput.indices && fromIndex in settingsListInput.indices) {
settingsListInput.apply { add(toIndex, removeAt(fromIndex)) }
}
}
val isEditing: Boolean =
settingsList.size != settingsListInput.size ||
settingsList.zip(settingsListInput).any { (item1, item2) -> item1 != item2 }
var showEditChannelDialog: Int? by rememberSaveable { mutableStateOf(null) }
var showChannelLegendDialog by rememberSaveable { mutableStateOf(false) }
if (showEditChannelDialog != null) {
val index = showEditChannelDialog ?: return
EditChannelDialog(
channelSettings = with(settingsListInput) { if (size > index) get(index) else channelSettings {} },
modemPresetName = modemPresetName,
onAddClick = {
if (settingsListInput.size > index) {
settingsListInput[index] = it
} else {
settingsListInput.add(it)
}
showEditChannelDialog = null
},
onDismissRequest = { showEditChannelDialog = null },
)
}
if (showChannelLegendDialog) {
ChannelLegendDialog(fwVersion) { showChannelLegendDialog = false }
}
Scaffold(
floatingActionButton = {
if (maxChannels > settingsListInput.size) {
FloatingActionButton(
onClick = {
if (maxChannels > settingsListInput.size) {
settingsListInput.add(channelSettings { psk = Channel.default.settings.psk })
showEditChannelDialog = settingsListInput.lastIndex
}
},
modifier = Modifier.padding(16.dp),
) {
Icon(Icons.TwoTone.Add, stringResource(R.string.add))
}
}
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
Column {
ChannelsConfigHeader(
frequency =
if (loraConfig.overrideFrequency != 0f) {
loraConfig.overrideFrequency
} else {
primaryChannel.radioFreq
},
slot =
if (loraConfig.channelNum != 0) {
loraConfig.channelNum
} else {
primaryChannel.channelNum
},
)
Text(
text = stringResource(R.string.press_and_drag),
fontSize = 11.sp,
modifier = Modifier.padding(start = 16.dp),
)
ChannelLegend { showChannelLegendDialog = true }
val locationChannel = determineLocationSharingChannel(fwVersion, settingsListInput.toList())
LazyColumn(
modifier =
Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current),
state = listState,
contentPadding = PaddingValues(horizontal = 16.dp),
) {
dragDropItemsIndexed(items = settingsListInput, dragDropState = dragDropState) {
index,
channel,
isDragging,
->
ChannelCard(
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = enabled,
channelSettings = channel,
loraConfig = loraConfig,
onEditClick = { showEditChannelDialog = index },
onDeleteClick = { settingsListInput.removeAt(index) },
sharesLocation = locationChannel == index,
)
}
item { Spacer(modifier = Modifier.weight(1f)) }
item {
PreferenceFooter(
enabled = enabled && isEditing,
negativeText = R.string.cancel,
onNegativeClicked = {
focusManager.clearFocus()
settingsListInput.clear()
settingsListInput.addAll(settingsList)
},
positiveText = R.string.send,
onPositiveClicked = {
focusManager.clearFocus()
onPositiveClicked(settingsListInput)
},
)
}
}
}
AnimatedVisibility(
visible = maxChannels > settingsListInput.size,
modifier = Modifier.align(Alignment.BottomEnd),
enter =
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
exit =
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
) {}
}
}
}
@Composable
private fun ChannelsConfigHeader(frequency: Float, slot: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
PreferenceCategory(text = stringResource(R.string.channels))
Column {
Text(text = "${stringResource(R.string.freq)}: ${frequency}MHz", fontSize = 11.sp)
Text(text = "${stringResource(R.string.slot)}: $slot", fontSize = 11.sp)
}
}
}
/**
* Determines what [Channel] if any is enabled to conduct automatic location sharing.
*
* @param firmwareVersion of the connected node.
* @param settingsList Current list of channels on the node.
* @return the index of the channel within `settingsList`.
*/
private fun determineLocationSharingChannel(firmwareVersion: DeviceVersion, settingsList: List<ChannelSettings>): Int {
var output = -1
if (firmwareVersion >= DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) {
/* Essentially the first index with the setting enabled */
for ((i, settings) in settingsList.withIndex()) {
if (settings.moduleSettings.positionPrecision > 0) {
output = i
break
}
}
} else {
/* Only the primary channel at index 0 can share locations automatically */
val primary = settingsList[0]
if (primary.moduleSettings.positionPrecision > 0) {
output = 0
}
}
return output
}
@Preview(showBackground = true)
@Composable
private fun ChannelSettingsPreview() {
ChannelSettingsItemList(
title = "Channels",
onBack = {},
settingsList =
listOf(
channelSettings {
psk = Channel.default.settings.psk
name = Channel.default.name
},
channelSettings { name = stringResource(R.string.channel_name) },
),
loraConfig = Channel.default.loraConfig,
firmwareVersion = "1.3.2",
enabled = true,
onPositiveClicked = {},
)
}

View file

@ -1,70 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.google.protobuf.MessageLite
/**
* A state holder for managing config data within a Composable.
*
* This class encapsulates the common logic for handling editable state that is derived from an initial value. It tracks
* whether the current value has been modified ("dirty"), and provides simple methods to save the changes or reset to
* the initial state.
*
* @param T The type of the data being managed, typically a Protobuf message.
* @property initialValue The original, unmodified value of the config data.
*/
class ConfigState<T : MessageLite>(private val initialValue: T) {
var value by mutableStateOf(initialValue)
val isDirty: Boolean
get() = value != initialValue
fun reset() {
value = initialValue
}
companion object {
fun <T : MessageLite> saver(initialValue: T): Saver<ConfigState<T>, ByteArray> = Saver(
save = { it.value.toByteArray() },
restore = {
ConfigState(initialValue).apply {
@Suppress("UNCHECKED_CAST")
value = initialValue.parserForType.parseFrom(it) as T
}
},
)
}
}
/**
* Creates and remembers a [ConfigState] instance, correctly handling process death and recomposition. When the
* `initialValue` changes, the config state will be reset.
*
* @param initialValue The initial value to populate the config with. The config will be reset if this value changes
* across recompositions.
*/
@Composable
fun <T : MessageLite> rememberConfigState(initialValue: T): ConfigState<T> =
rememberSaveable(initialValue, saver = ConfigState.saver(initialValue)) { ConfigState(initialValue) }

View file

@ -1,151 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun DetectionSensorConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val detectionSensorConfig = state.moduleConfig.detectionSensor
val formState = rememberConfigState(initialValue = detectionSensorConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.detection_sensor),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { detectionSensor = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.detection_sensor_config)) }
item {
SwitchPreference(
title = stringResource(R.string.detection_sensor_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.minimum_broadcast_seconds),
value = formState.value.minimumBroadcastSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { minimumBroadcastSecs = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.state_broadcast_seconds),
value = formState.value.stateBroadcastSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { stateBroadcastSecs = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.send_bell_with_alert_message),
checked = formState.value.sendBell,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { sendBell = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.friendly_name),
value = formState.value.name,
maxSize = 19, // name max_size:20
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 { name = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.gpio_pin_to_monitor),
value = formState.value.monitorPin,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { monitorPin = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.detection_trigger_type),
enabled = state.connected,
items =
ModuleConfig.DetectionSensorConfig.TriggerType.entries
.filter { it != ModuleConfig.DetectionSensorConfig.TriggerType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.detectionTriggerType,
onItemSelected = { formState.value = formState.value.copy { detectionTriggerType = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.use_input_pullup_mode),
checked = formState.value.usePullup,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { usePullup = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,251 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
private val DeviceConfig.Role.description: Int
get() =
when (this) {
DeviceConfig.Role.CLIENT -> R.string.role_client_desc
DeviceConfig.Role.CLIENT_MUTE -> R.string.role_client_mute_desc
DeviceConfig.Role.ROUTER -> R.string.role_router_desc
DeviceConfig.Role.ROUTER_CLIENT -> R.string.role_router_client_desc
DeviceConfig.Role.REPEATER -> R.string.role_repeater_desc
DeviceConfig.Role.TRACKER -> R.string.role_tracker_desc
DeviceConfig.Role.SENSOR -> R.string.role_sensor_desc
DeviceConfig.Role.TAK -> R.string.role_tak_desc
DeviceConfig.Role.CLIENT_HIDDEN -> R.string.role_client_hidden_desc
DeviceConfig.Role.LOST_AND_FOUND -> R.string.role_lost_and_found_desc
DeviceConfig.Role.TAK_TRACKER -> R.string.role_tak_tracker_desc
DeviceConfig.Role.ROUTER_LATE -> R.string.role_router_late_desc
else -> R.string.unrecognized
}
private val DeviceConfig.RebroadcastMode.description: Int
get() =
when (this) {
DeviceConfig.RebroadcastMode.ALL -> R.string.rebroadcast_mode_all_desc
DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> R.string.rebroadcast_mode_all_skip_decoding_desc
DeviceConfig.RebroadcastMode.LOCAL_ONLY -> R.string.rebroadcast_mode_local_only_desc
DeviceConfig.RebroadcastMode.KNOWN_ONLY -> R.string.rebroadcast_mode_known_only_desc
DeviceConfig.RebroadcastMode.NONE -> R.string.rebroadcast_mode_none_desc
DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> R.string.rebroadcast_mode_core_portnums_only_desc
else -> R.string.unrecognized
}
@Composable
fun DeviceConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role) }
val infrastructureRoles = listOf(DeviceConfig.Role.ROUTER, DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
if (selectedRole in infrastructureRoles) {
RouterRoleConfirmationDialog(
onDismiss = { selectedRole = formState.value.role },
onConfirm = { formState.value = formState.value.copy { role = selectedRole } },
)
} else {
formState.value = formState.value.copy { role = selectedRole }
}
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.device),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { device = it }
viewModel.setConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.options)) }
item {
DropDownPreference(
title = stringResource(R.string.role),
enabled = state.connected,
selectedItem = formState.value.role,
onItemSelected = { selectedRole = it },
summary = stringResource(id = formState.value.role.description),
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.rebroadcast_mode),
enabled = state.connected,
selectedItem = formState.value.rebroadcastMode,
onItemSelected = { formState.value = formState.value.copy { rebroadcastMode = it } },
summary = stringResource(id = formState.value.rebroadcastMode.description),
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.nodeinfo_broadcast_interval),
value = formState.value.nodeInfoBroadcastSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { nodeInfoBroadcastSecs = it } },
)
}
item { PreferenceCategory(text = stringResource(R.string.hardware)) }
item {
SwitchPreference(
title = stringResource(R.string.double_tap_as_button_press),
summary = stringResource(id = R.string.config_device_doubleTapAsButtonPress_summary),
checked = formState.value.doubleTapAsButtonPress,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { doubleTapAsButtonPress = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.triple_click_adhoc_ping),
summary = stringResource(id = R.string.config_device_tripleClickAsAdHocPing_summary),
checked = !formState.value.disableTripleClick,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { disableTripleClick = !it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.led_heartbeat),
summary = stringResource(id = R.string.config_device_ledHeartbeatEnabled_summary),
checked = !formState.value.ledHeartbeatDisabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ledHeartbeatDisabled = !it } },
)
}
item { HorizontalDivider() }
item { PreferenceCategory(text = stringResource(R.string.debug)) }
item {
EditTextPreference(
title = stringResource(R.string.time_zone),
value = formState.value.tzdef,
summary = stringResource(id = R.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 } },
)
}
item { PreferenceCategory(text = stringResource(R.string.gpio)) }
item {
EditTextPreference(
title = stringResource(R.string.button_gpio),
value = formState.value.buttonGpio,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { buttonGpio = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.buzzer_gpio),
value = formState.value.buzzerGpio,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { buzzerGpio = it } },
)
}
}
}
@Composable
fun RouterRoleConfirmationDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
val dialogTitle = stringResource(R.string.are_you_sure)
val annotatedDialogText =
AnnotatedString.fromHtml(
htmlString = stringResource(R.string.router_role_confirmation_text),
linkStyles = TextLinkStyles(style = SpanStyle(color = Color.Blue)),
)
var confirmed by rememberSaveable { mutableStateOf(false) }
AlertDialog(
title = { Text(text = dialogTitle) },
text = {
Column {
Text(text = annotatedDialogText)
Row(
modifier = Modifier.fillMaxWidth().clickable(true) { confirmed = !confirmed },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = confirmed, onCheckedChange = { confirmed = it })
Text(stringResource(R.string.i_know_what_i_m_doing))
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm, enabled = confirmed) { Text(stringResource(R.string.accept)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}

View file

@ -1,190 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun DisplayConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val displayConfig = state.radioConfig.display
val formState = rememberConfigState(initialValue = displayConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.display),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { display = it }
viewModel.setConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.display_config)) }
item {
SwitchPreference(
title = stringResource(R.string.always_point_north),
summary = stringResource(id = R.string.config_display_compass_north_top_summary),
checked = formState.value.compassNorthTop,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { compassNorthTop = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.use_12h_format),
summary = stringResource(R.string.display_time_in_12h_format),
enabled = state.connected,
checked = formState.value.use12HClock,
onCheckedChange = { formState.value = formState.value.copy { use12HClock = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.bold_heading),
summary = stringResource(id = R.string.config_display_heading_bold_summary),
checked = formState.value.headingBold,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { headingBold = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.display_units),
summary = stringResource(id = R.string.config_display_units_summary),
enabled = state.connected,
items =
DisplayConfig.DisplayUnits.entries
.filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.units,
onItemSelected = { formState.value = formState.value.copy { units = it } },
)
}
item { HorizontalDivider() }
item { PreferenceCategory(text = stringResource(R.string.advanced)) }
item {
EditTextPreference(
title = stringResource(R.string.screen_on_for),
summary = stringResource(id = R.string.config_display_screen_on_secs_summary),
value = formState.value.screenOnSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { screenOnSecs = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.carousel_interval),
summary = stringResource(id = R.string.config_display_auto_screen_carousel_secs_summary),
value = formState.value.autoScreenCarouselSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { autoScreenCarouselSecs = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.wake_on_tap_or_motion),
summary = stringResource(id = R.string.config_display_wake_on_tap_or_motion_summary),
checked = formState.value.wakeOnTapOrMotion,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { wakeOnTapOrMotion = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.flip_screen),
summary = stringResource(id = R.string.config_display_flip_screen_summary),
checked = formState.value.flipScreen,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { flipScreen = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.display_mode),
summary = stringResource(id = R.string.config_display_displaymode_summary),
enabled = state.connected,
items =
DisplayConfig.DisplayMode.entries
.filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.displaymode,
onItemSelected = { formState.value = formState.value.copy { displaymode = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.oled_type),
summary = stringResource(id = R.string.config_display_oled_summary),
enabled = state.connected,
items =
DisplayConfig.OledType.entries
.filter { it != DisplayConfig.OledType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.oled,
onItemSelected = { formState.value = formState.value.copy { oled = it } },
)
}
item { HorizontalDivider() }
item {
DropDownPreference(
title = stringResource(R.string.compass_orientation),
enabled = state.connected,
items =
DisplayConfig.CompassOrientation.entries
.filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.compassOrientation,
onItemSelected = { formState.value = formState.value.copy { compassOrientation = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,161 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.copy
import org.meshtastic.core.model.Channel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PositionPrecisionPreference
import org.meshtastic.core.ui.component.SwitchPreference
@Suppress("LongMethod")
@Composable
fun EditChannelDialog(
channelSettings: ChannelProtos.ChannelSettings,
onAddClick: (ChannelProtos.ChannelSettings) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
modemPresetName: String = stringResource(R.string.default_),
) {
val focusManager = LocalFocusManager.current
var isFocused by remember { mutableStateOf(false) }
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier.fillMaxWidth()) {
EditTextPreference(
title = stringResource(R.string.channel_name),
value = if (isFocused) channelInput.name else channelInput.name.ifEmpty { modemPresetName },
maxSize = 11, // name max_size:12
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
channelInput =
channelInput.copy {
name = it.trim()
if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey()
}
},
onFocusChanged = { isFocused = it.isFocused },
)
EditBase64Preference(
title = "PSK",
value = channelInput.psk,
enabled = true,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
val fullPsk = Channel(channelSettings { psk = it }).psk
if (fullPsk.size() in setOf(0, 16, 32)) {
channelInput = channelInput.copy { psk = it }
}
},
onGenerateKey = { channelInput = channelInput.copy { psk = Channel.getRandomKey() } },
)
SwitchPreference(
title = stringResource(R.string.uplink_enabled),
checked = channelInput.uplinkEnabled,
enabled = true,
onCheckedChange = { channelInput = channelInput.copy { uplinkEnabled = it } },
padding = PaddingValues(0.dp),
)
SwitchPreference(
title = stringResource(R.string.downlink_enabled),
checked = channelInput.downlinkEnabled,
enabled = true,
onCheckedChange = { channelInput = channelInput.copy { downlinkEnabled = it } },
padding = PaddingValues(0.dp),
)
PositionPrecisionPreference(
enabled = true,
value = channelInput.moduleSettings.positionPrecision,
onValueChanged = {
val module = channelInput.moduleSettings.copy { positionPrecision = it }
channelInput = channelInput.copy { moduleSettings = module }
},
)
}
},
confirmButton = {
FlowRow(
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Button(modifier = modifier.weight(1f), onClick = { onAddClick(channelInput) }, enabled = true) {
Text(stringResource(R.string.save))
}
}
},
)
}
@Preview(showBackground = true)
@Composable
private fun EditChannelDialogPreview() {
EditChannelDialog(
channelSettings =
channelSettings {
psk = Channel.default.settings.psk
name = Channel.default.name
},
onAddClick = {},
onDismissRequest = {},
)
}

View file

@ -1,136 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.google.protobuf.Descriptors
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SwitchPreference
private const val SUPPORTED_FIELDS = 7
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun EditDeviceProfileDialog(
title: String,
deviceProfile: DeviceProfile,
onConfirm: (DeviceProfile) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val state = remember {
val fields =
deviceProfile.descriptorForType.fields.filter {
it.number < SUPPORTED_FIELDS
} // TODO add ringtone & canned messages
mutableStateMapOf<Descriptors.FieldDescriptor, Boolean>().apply {
putAll(fields.associateWith(deviceProfile::hasField))
}
}
AlertDialog(
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier.fillMaxWidth()) {
Text(
text = title,
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
HorizontalDivider()
state.keys
.sortedBy { it.number }
.forEach { field ->
SwitchPreference(
title = field.name,
checked = state[field] == true,
enabled = deviceProfile.hasField(field),
onCheckedChange = { state[field] = it },
padding = PaddingValues(0.dp),
)
}
HorizontalDivider()
}
},
confirmButton = {
FlowRow(
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
Button(
modifier = modifier.weight(1f),
onClick = {
val builder = DeviceProfile.newBuilder()
deviceProfile.allFields.forEach { (field, value) ->
if (state[field] == true) {
builder.setField(field, value)
}
}
onConfirm(builder.build())
},
enabled = state.values.any { it },
) {
Text(stringResource(R.string.save))
}
}
},
)
}
@Preview(showBackground = true)
@Composable
private fun EditDeviceProfileDialogPreview() {
EditDeviceProfileDialog(
title = "Export configuration",
deviceProfile = DeviceProfile.getDefaultInstance(),
onConfirm = {},
onDismiss = {},
)
}

View file

@ -1,249 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
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.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TextDividerPreference
@Composable
fun ExternalNotificationConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val extNotificationConfig = state.moduleConfig.externalNotification
val ringtone = state.ringtone
val formState = rememberConfigState(initialValue = extNotificationConfig)
var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.external_notification),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
if (ringtoneInput != ringtone) {
viewModel.setRingtone(ringtoneInput)
}
if (formState.value != extNotificationConfig) {
val config = moduleConfig { externalNotification = formState.value }
viewModel.setModuleConfig(config)
}
},
) {
item { PreferenceCategory(text = stringResource(R.string.external_notification_config)) }
item {
SwitchPreference(
title = stringResource(R.string.external_notification_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item {
TextDividerPreference(stringResource(R.string.notifications_on_message_receipt), enabled = state.connected)
}
item {
SwitchPreference(
title = stringResource(R.string.alert_message_led),
checked = formState.value.alertMessage,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertMessage = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.alert_message_buzzer),
checked = formState.value.alertMessageBuzzer,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertMessageBuzzer = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.alert_message_vibra),
checked = formState.value.alertMessageVibra,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertMessageVibra = it } },
)
}
item {
TextDividerPreference(
stringResource(R.string.notifications_on_alert_bell_receipt),
enabled = state.connected,
)
}
item {
SwitchPreference(
title = stringResource(R.string.alert_bell_led),
checked = formState.value.alertBell,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertBell = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.alert_bell_buzzer),
checked = formState.value.alertBellBuzzer,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertBellBuzzer = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.alert_bell_vibra),
checked = formState.value.alertBellVibra,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertBellVibra = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.output_led_gpio),
value = formState.value.output,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { output = it } },
)
}
if (formState.value.output != 0) {
item {
SwitchPreference(
title = stringResource(R.string.output_led_active_high),
checked = formState.value.active,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { active = it } },
)
}
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.output_buzzer_gpio),
value = formState.value.outputBuzzer,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { outputBuzzer = it } },
)
}
if (formState.value.outputBuzzer != 0) {
item {
SwitchPreference(
title = stringResource(R.string.use_pwm_buzzer),
checked = formState.value.usePwm,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { usePwm = it } },
)
}
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.output_vibra_gpio),
value = formState.value.outputVibra,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { outputVibra = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.output_duration_milliseconds),
value = formState.value.outputMs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { outputMs = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.nag_timeout_seconds),
value = formState.value.nagTimeout,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { nagTimeout = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.ringtone),
value = ringtoneInput,
maxSize = 230, // ringtone max_size:231
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { ringtoneInput = it },
)
}
item {
SwitchPreference(
title = stringResource(R.string.use_i2s_as_buzzer),
checked = formState.value.useI2SAsBuzzer,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { useI2SAsBuzzer = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,251 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.RegionInfo
import org.meshtastic.core.model.numChannels
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceDivider
import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
@Composable
fun LoRaConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val loraConfig = state.radioConfig.lora
val primarySettings = state.channelList.getOrNull(0) ?: return
val formState = rememberConfigState(initialValue = loraConfig)
val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.lora),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { lora = it }
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(R.string.options)) {
DropDownPreference(
title = stringResource(R.string.region_frequency_plan),
summary = stringResource(id = R.string.config_lora_region_summary),
enabled = state.connected,
items = RegionInfo.entries.map { it.regionCode to it.description },
selectedItem = formState.value.region,
onItemSelected = { formState.value = formState.value.copy { region = it } },
)
PreferenceDivider()
SwitchPreference(
title = stringResource(R.string.use_modem_preset),
checked = formState.value.usePreset,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { usePreset = it } },
containerColor = Color.Transparent,
)
PreferenceDivider()
if (formState.value.usePreset) {
DropDownPreference(
title = stringResource(R.string.modem_preset),
summary = stringResource(id = R.string.config_lora_modem_preset_summary),
enabled = state.connected && formState.value.usePreset,
items = ChannelOption.entries.map { it.modemPreset to stringResource(it.labelRes) },
selectedItem = formState.value.modemPreset,
onItemSelected = { formState.value = formState.value.copy { modemPreset = it } },
)
} else {
EditTextPreference(
title = stringResource(R.string.bandwidth),
value = formState.value.bandwidth,
enabled = state.connected && !formState.value.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { bandwidth = it } },
)
PreferenceDivider()
EditTextPreference(
title = stringResource(R.string.spread_factor),
value = formState.value.spreadFactor,
enabled = state.connected && !formState.value.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { spreadFactor = it } },
)
PreferenceDivider()
EditTextPreference(
title = stringResource(R.string.coding_rate),
value = formState.value.codingRate,
enabled = state.connected && !formState.value.usePreset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { codingRate = it } },
)
}
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
TitledCard(title = stringResource(R.string.advanced)) {
SwitchPreference(
title = stringResource(R.string.ignore_mqtt),
checked = formState.value.ignoreMqtt,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ignoreMqtt = it } },
containerColor = Color.Transparent,
)
PreferenceDivider()
SwitchPreference(
title = stringResource(R.string.ok_to_mqtt),
checked = formState.value.configOkToMqtt,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { configOkToMqtt = it } },
containerColor = Color.Transparent,
)
PreferenceDivider()
SwitchPreference(
title = stringResource(R.string.tx_enabled),
checked = formState.value.txEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { txEnabled = it } },
containerColor = Color.Transparent,
)
PreferenceDivider()
EditTextPreference(
title = stringResource(R.string.hop_limit),
summary = stringResource(id = R.string.config_lora_hop_limit_summary),
value = formState.value.hopLimit,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { hopLimit = it } },
)
PreferenceDivider()
var isFocusedSlot by remember { mutableStateOf(false) }
EditTextPreference(
title = stringResource(R.string.frequency_slot),
summary = stringResource(id = R.string.config_lora_frequency_slot_summary),
value =
if (isFocusedSlot || formState.value.channelNum != 0) {
formState.value.channelNum
} else {
primaryChannel.channelNum
},
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onFocusChanged = { isFocusedSlot = it.isFocused },
onValueChanged = {
if (it <= formState.value.numChannels) { // total num of LoRa channels
formState.value = formState.value.copy { channelNum = it }
}
},
)
PreferenceDivider()
SwitchPreference(
title = stringResource(R.string.sx126x_rx_boosted_gain),
checked = formState.value.sx126XRxBoostedGain,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { sx126XRxBoostedGain = it } },
containerColor = Color.Transparent,
)
PreferenceDivider()
var isFocusedOverride by remember { mutableStateOf(false) }
EditTextPreference(
title = stringResource(R.string.override_frequency_mhz),
value =
if (isFocusedOverride || formState.value.overrideFrequency != 0f) {
formState.value.overrideFrequency
} else {
primaryChannel.radioFreq
},
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onFocusChanged = { isFocusedOverride = it.isFocused },
onValueChanged = { formState.value = formState.value.copy { overrideFrequency = it } },
)
PreferenceDivider()
SignedIntegerEditTextPreference(
title = stringResource(R.string.tx_power_dbm),
value = formState.value.txPower,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { txPower = it } },
)
if (viewModel.hasPaFan) {
SwitchPreference(
title = stringResource(R.string.pa_fan_disabled),
checked = formState.value.paFanDisabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { paFanDisabled = it } },
containerColor = Color.Transparent,
)
}
}
}
}
}

View file

@ -1,218 +0,0 @@
/*
* Copyright (c) 2025 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/>.
*/
@file:Suppress("LongMethod")
package com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditPasswordPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun MQTTConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val destNum = destNode?.num
val mqttConfig = state.moduleConfig.mqtt
val formState = rememberConfigState(initialValue = mqttConfig)
if (!formState.value.mapReportSettings.shouldReportLocation) {
val settings =
formState.value.mapReportSettings.copy {
this.shouldReportLocation = viewModel.shouldReportLocation(destNum)
}
formState.value = formState.value.copy { mapReportSettings = settings }
}
val consentValid =
if (formState.value.mapReportingEnabled) {
formState.value.mapReportSettings.shouldReportLocation &&
mqttConfig.mapReportSettings.publishIntervalSecs >= MIN_INTERVAL_SECS
} else {
true
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.mqtt),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected && consentValid,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { mqtt = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.mqtt_config)) }
item {
SwitchPreference(
title = stringResource(R.string.mqtt_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.address),
value = formState.value.address,
maxSize = 63, // address max_size:64
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 { address = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.username),
value = formState.value.username,
maxSize = 63, // username max_size:64
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 { username = it } },
)
}
item {
EditPasswordPreference(
title = stringResource(R.string.password),
value = formState.value.password,
maxSize = 63, // password max_size:64
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { password = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.encryption_enabled),
checked = formState.value.encryptionEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { encryptionEnabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.json_output_enabled),
checked = formState.value.jsonEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { jsonEnabled = it } },
)
}
item { HorizontalDivider() }
item {
val defaultAddress = stringResource(R.string.default_mqtt_address)
val isDefault = formState.value.address.isEmpty() || formState.value.address.contains(defaultAddress)
val enforceTls = isDefault && formState.value.proxyToClientEnabled
SwitchPreference(
title = stringResource(R.string.tls_enabled),
checked = formState.value.tlsEnabled || enforceTls,
enabled = state.connected && !enforceTls,
onCheckedChange = { formState.value = formState.value.copy { tlsEnabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.root_topic),
value = formState.value.root,
maxSize = 31, // root max_size:32
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 { root = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.proxy_to_client_enabled),
checked = formState.value.proxyToClientEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { proxyToClientEnabled = it } },
)
}
item { HorizontalDivider() }
// mqtt map reporting opt in
item { PreferenceCategory(text = stringResource(R.string.map_reporting)) }
item {
MapReportingPreference(
mapReportingEnabled = formState.value.mapReportingEnabled,
onMapReportingEnabledChanged = { formState.value = formState.value.copy { mapReportingEnabled = it } },
shouldReportLocation = formState.value.mapReportSettings.shouldReportLocation,
onShouldReportLocationChanged = {
viewModel.setShouldReportLocation(destNum, it)
val settings = formState.value.mapReportSettings.copy { this.shouldReportLocation = it }
formState.value = formState.value.copy { mapReportSettings = settings }
},
positionPrecision = formState.value.mapReportSettings.positionPrecision,
onPositionPrecisionChanged = {
val settings = formState.value.mapReportSettings.copy { positionPrecision = it }
formState.value = formState.value.copy { mapReportSettings = settings }
},
publishIntervalSecs = formState.value.mapReportSettings.publishIntervalSecs,
onPublishIntervalSecsChanged = {
val settings = formState.value.mapReportSettings.copy { publishIntervalSecs = it }
formState.value = formState.value.copy { mapReportSettings = settings }
},
enabled = state.connected,
focusManager = focusManager,
)
}
item { HorizontalDivider() }
}
}
private const val MIN_INTERVAL_SECS = 3600

View file

@ -1,153 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
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.focus.FocusManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.precisionBitsToMeters
import kotlin.math.roundToInt
private const val POSITION_PRECISION_MIN = 12
private const val POSITION_PRECISION_MAX = 15
@Suppress("LongMethod")
@Composable
fun MapReportingPreference(
mapReportingEnabled: Boolean = false,
onMapReportingEnabledChanged: (Boolean) -> Unit = {},
shouldReportLocation: Boolean = false,
onShouldReportLocationChanged: (Boolean) -> Unit = {},
positionPrecision: Int = 14,
onPositionPrecisionChanged: (Int) -> Unit = {},
publishIntervalSecs: Int = 3600,
onPublishIntervalSecsChanged: (Int) -> Unit = {},
enabled: Boolean,
focusManager: FocusManager,
) {
Column {
var showMapReportingWarning by rememberSaveable { mutableStateOf(mapReportingEnabled) }
LaunchedEffect(mapReportingEnabled) { showMapReportingWarning = mapReportingEnabled }
SwitchPreference(
title = stringResource(R.string.map_reporting),
summary = stringResource(R.string.map_reporting_summary),
checked = showMapReportingWarning,
enabled = enabled,
onCheckedChange = { checked ->
showMapReportingWarning = checked
if (checked && shouldReportLocation) {
onMapReportingEnabledChanged(true)
} else if (!checked) {
onMapReportingEnabledChanged(false)
}
},
)
AnimatedVisibility(showMapReportingWarning) {
Card(modifier = Modifier.padding(16.dp)) {
Text(text = stringResource(R.string.map_reporting_consent_header), modifier = Modifier.padding(16.dp))
HorizontalDivider()
Text(stringResource(R.string.map_reporting_consent_text), modifier = Modifier.padding(16.dp))
SwitchPreference(
title = stringResource(R.string.i_agree),
summary = stringResource(R.string.i_agree_to_share_my_location),
checked = shouldReportLocation,
enabled = enabled,
onCheckedChange = { checked ->
if (checked) {
onMapReportingEnabledChanged(true)
onShouldReportLocationChanged(true)
} else {
onShouldReportLocationChanged(false)
}
},
containerColor = CardDefaults.cardColors().containerColor,
)
if (shouldReportLocation && mapReportingEnabled) {
Slider(
modifier = Modifier.Companion.padding(horizontal = 16.dp),
value = positionPrecision.toFloat(),
onValueChange = { onPositionPrecisionChanged(it.roundToInt()) },
enabled = enabled,
valueRange = POSITION_PRECISION_MIN.toFloat()..POSITION_PRECISION_MAX.toFloat(),
steps = POSITION_PRECISION_MAX - POSITION_PRECISION_MIN - 1,
)
val precisionMeters = precisionBitsToMeters(positionPrecision).toInt()
val unit = DistanceUnit.Companion.getFromLocale()
Text(
text = precisionMeters.toDistanceString(unit),
modifier = Modifier.Companion.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
overflow = TextOverflow.Companion.Ellipsis,
maxLines = 1,
)
EditTextPreference(
modifier = Modifier.Companion.padding(bottom = 16.dp),
title = stringResource(R.string.map_reporting_interval_seconds),
value = publishIntervalSecs,
isError = publishIntervalSecs < 3600,
enabled = enabled,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = onPublishIntervalSecsChanged,
)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MapReportingPreview() {
val focusManager = LocalFocusManager.current
MapReportingPreference(
mapReportingEnabled = true,
onMapReportingEnabledChanged = {},
shouldReportLocation = true,
onShouldReportLocationChanged = {},
positionPrecision = 5,
onPositionPrecisionChanged = {},
enabled = true,
focusManager = focusManager,
)
}

View file

@ -1,89 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun NeighborInfoConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val neighborInfoConfig = state.moduleConfig.neighborInfo
val formState = rememberConfigState(initialValue = neighborInfoConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.neighbor_info),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { neighborInfo = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.neighbor_info_config)) }
item {
SwitchPreference(
title = stringResource(R.string.neighbor_info_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.update_interval_seconds),
value = formState.value.updateInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { updateInterval = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.transmit_over_lora),
summary = stringResource(id = R.string.config_device_transmitOverLora_summary),
checked = formState.value.transmitOverLora,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { transmitOverLora = it } },
)
HorizontalDivider()
}
}
}

View file

@ -1,294 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ConfigProtos.Config.NetworkConfig
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditIPv4Preference
import org.meshtastic.core.ui.component.EditPasswordPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
SimpleAlertDialog(title = R.string.error, text = R.string.wifi_qr_code_error, onDismiss = onDismiss)
@Composable
fun NetworkConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network
val formState = rememberConfigState(initialValue = networkConfig)
var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) }
if (showScanErrorDialog) {
ScanErrorDialog { showScanErrorDialog = false }
}
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
val (ssid, psk) = extractWifiCredentials(result.contents)
if (ssid != null && psk != null) {
formState.value =
formState.value.copy {
wifiSsid = ssid
wifiPsk = psk
}
} else {
showScanErrorDialog = true
}
}
}
fun zxingScan() {
val zxingScan =
ScanOptions().apply {
setCameraId(0)
setPrompt("")
setBeepEnabled(false)
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
}
barcodeLauncher.launch(zxingScan)
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.network),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { network = it }
viewModel.setConfig(config)
},
) {
if (state.metadata?.hasWifi == true) {
item { PreferenceCategory(text = stringResource(R.string.wifi_config)) }
item {
SwitchPreference(
title = stringResource(R.string.wifi_enabled),
summary = stringResource(id = R.string.config_network_wifi_enabled_summary),
checked = formState.value.wifiEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { wifiEnabled = it } },
)
HorizontalDivider()
}
item {
EditTextPreference(
title = stringResource(R.string.ssid),
value = formState.value.wifiSsid,
maxSize = 32, // wifi_ssid max_size:33
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 { wifiSsid = it } },
)
}
item {
EditPasswordPreference(
title = stringResource(R.string.password),
value = formState.value.wifiPsk,
maxSize = 64, // wifi_psk max_size:65
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { wifiPsk = it } },
)
}
item {
Button(
onClick = { zxingScan() },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp).height(48.dp),
enabled = state.connected,
) {
Text(text = stringResource(R.string.wifi_qr_code_scan))
}
}
}
if (state.metadata?.hasEthernet == true) {
item { PreferenceCategory(text = stringResource(R.string.ethernet_config)) }
item {
SwitchPreference(
title = stringResource(R.string.ethernet_enabled),
summary = stringResource(id = R.string.config_network_eth_enabled_summary),
checked = formState.value.ethEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ethEnabled = it } },
)
HorizontalDivider()
}
}
if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) {
item { PreferenceCategory(text = stringResource(R.string.udp_config)) }
item {
SwitchPreference(
title = stringResource(R.string.udp_enabled),
summary = stringResource(id = R.string.config_network_udp_enabled_summary),
checked = formState.value.enabledProtocols == 1,
enabled = state.connected,
onCheckedChange = {
formState.value =
formState.value.copy { if (it) enabledProtocols = 1 else enabledProtocols = 0 }
},
)
}
item { HorizontalDivider() }
}
item { PreferenceCategory(text = stringResource(R.string.advanced)) }
item {
EditTextPreference(
title = stringResource(R.string.ntp_server),
value = formState.value.ntpServer,
maxSize = 32, // ntp_server max_size:33
enabled = state.connected,
isError = formState.value.ntpServer.isEmpty(),
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { ntpServer = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.rsyslog_server),
value = formState.value.rsyslogServer,
maxSize = 32, // rsyslog_server max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { rsyslogServer = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.ipv4_mode),
enabled = state.connected,
items =
NetworkConfig.AddressMode.entries
.filter { it != NetworkConfig.AddressMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.addressMode,
onItemSelected = { formState.value = formState.value.copy { addressMode = it } },
)
HorizontalDivider()
}
item {
EditIPv4Preference(
title = stringResource(R.string.ip),
value = formState.value.ipv4Config.ip,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { ip = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
)
}
item {
EditIPv4Preference(
title = stringResource(R.string.gateway),
value = formState.value.ipv4Config.gateway,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { gateway = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
)
}
item {
EditIPv4Preference(
title = stringResource(R.string.subnet),
value = formState.value.ipv4Config.subnet,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { subnet = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
)
}
item {
EditIPv4Preference(
title = "DNS",
value = formState.value.ipv4Config.dns,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { dns = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
)
}
item { HorizontalDivider() }
}
}
private fun extractWifiCredentials(qrCode: String) =
Regex("""WIFI:S:(.*?);.*?P:(.*?);""").find(qrCode)?.destructured?.let { (ssid, password) -> ssid to password }
?: (null to null)

View file

@ -1,94 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.settings.radio.ResponseState
import org.meshtastic.core.strings.R
@Composable
fun <T> PacketResponseStateDialog(state: ResponseState<T>, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
AlertDialog(
onDismissRequest = {},
shape = RoundedCornerShape(16.dp),
title = {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
if (state is ResponseState.Loading) {
val progress by
animateFloatAsState(
targetValue = state.completed.toFloat() / state.total.toFloat(),
label = "progress",
)
Text("%.0f%%".format(progress * 100))
LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
if (state.total == state.completed) onComplete()
}
if (state is ResponseState.Success) {
Text(text = stringResource(id = R.string.delivery_confirmed))
}
if (state is ResponseState.Error) {
Text(text = stringResource(id = R.string.error), minLines = 2)
Text(text = state.error.asString())
}
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
Button(
onClick = {
onDismiss()
if (state is ResponseState.Success || state is ResponseState.Error) {
backDispatcher?.onBackPressed()
}
},
modifier = Modifier.padding(top = 16.dp),
) {
Text(stringResource(R.string.close))
}
}
},
)
}
@Preview(showBackground = true)
@Composable
private fun PacketResponseStateDialogPreview() {
PacketResponseStateDialog(state = ResponseState.Loading(total = 17, completed = 5))
}

View file

@ -1,99 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun PaxcounterConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val paxcounterConfig = state.moduleConfig.paxcounter
val formState = rememberConfigState(initialValue = paxcounterConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.paxcounter),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { paxcounter = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.paxcounter_config)) }
item {
SwitchPreference(
title = stringResource(R.string.paxcounter_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.update_interval_seconds),
value = formState.value.paxcounterUpdateInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { paxcounterUpdateInterval = it } },
)
}
item {
SignedIntegerEditTextPreference(
title = stringResource(R.string.wifi_rssi_threshold_defaults_to_80),
value = formState.value.wifiThreshold,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { wifiThreshold = it } },
)
}
item {
SignedIntegerEditTextPreference(
title = stringResource(R.string.ble_rssi_threshold_defaults_to_80),
value = formState.value.bleThreshold,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { bleThreshold = it } },
)
}
}
}

View file

@ -1,303 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import android.Manifest
import android.annotation.SuppressLint
import android.location.Location
import android.os.Build
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.core.location.LocationCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.ConfigProtos.Config.PositionConfig
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
import org.meshtastic.core.model.Position
import org.meshtastic.core.strings.R
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.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PositionConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
var phoneLocation: Location? by remember { mutableStateOf(null) }
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
val formState = rememberConfigState(initialValue = positionConfig)
var locationInput by rememberSaveable { mutableStateOf(currentPosition) }
val locationPermissionState =
rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) { granted ->
if (granted) {
@SuppressLint("MissingPermission")
coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() }
}
}
LaunchedEffect(phoneLocation) {
phoneLocation?.let { phoneLoc ->
locationInput =
Position(
latitude = phoneLoc.latitude,
longitude = phoneLoc.longitude,
altitude =
LocationCompat.hasMslAltitude(phoneLoc).let {
if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
phoneLoc.mslAltitudeMeters.toInt()
} else {
phoneLoc.altitude.toInt()
}
},
)
}
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.position),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
if (formState.value.fixedPosition) {
if (locationInput != currentPosition) {
viewModel.setFixedPosition(locationInput)
}
} else {
if (positionConfig.fixedPosition) {
// fixed position changed from enabled to disabled
viewModel.removeFixedPosition()
}
}
val config = config { position = it }
viewModel.setConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.position_packet)) }
item {
EditTextPreference(
title = stringResource(R.string.broadcast_interval),
summary = stringResource(id = R.string.config_position_broadcast_secs_summary),
value = formState.value.positionBroadcastSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { positionBroadcastSecs = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.smart_position),
checked = formState.value.positionBroadcastSmartEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { positionBroadcastSmartEnabled = it } },
)
}
item { HorizontalDivider() }
if (formState.value.positionBroadcastSmartEnabled) {
item {
EditTextPreference(
title = stringResource(R.string.minimum_interval),
summary =
stringResource(id = R.string.config_position_broadcast_smart_minimum_interval_secs_summary),
value = formState.value.broadcastSmartMinimumIntervalSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
formState.value = formState.value.copy { broadcastSmartMinimumIntervalSecs = it }
},
)
}
item {
EditTextPreference(
title = stringResource(R.string.minimum_distance),
summary = stringResource(id = R.string.config_position_broadcast_smart_minimum_distance_summary),
value = formState.value.broadcastSmartMinimumDistance,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { broadcastSmartMinimumDistance = it } },
)
}
}
item { PreferenceCategory(text = stringResource(R.string.device_gps)) }
item {
SwitchPreference(
title = stringResource(R.string.fixed_position),
checked = formState.value.fixedPosition,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { fixedPosition = it } },
)
}
item { HorizontalDivider() }
if (formState.value.fixedPosition) {
item {
EditTextPreference(
title = stringResource(R.string.latitude),
value = locationInput.latitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value >= -90 && value <= 90.0) {
locationInput = locationInput.copy(latitude = value)
}
},
)
}
item {
EditTextPreference(
title = stringResource(R.string.longitude),
value = locationInput.longitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value ->
if (value >= -180 && value <= 180.0) {
locationInput = locationInput.copy(longitude = value)
}
},
)
}
item {
EditTextPreference(
title = stringResource(R.string.altitude),
value = locationInput.altitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { value -> locationInput = locationInput.copy(altitude = value) },
)
}
item {
TextButton(
enabled = state.connected,
onClick = { coroutineScope.launch { locationPermissionState.launchPermissionRequest() } },
) {
Text(text = stringResource(R.string.position_config_set_fixed_from_phone))
}
}
}
item {
DropDownPreference(
title = stringResource(R.string.gps_mode),
enabled = state.connected,
items =
ConfigProtos.Config.PositionConfig.GpsMode.entries
.filter { it != ConfigProtos.Config.PositionConfig.GpsMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.gpsMode,
onItemSelected = { formState.value = formState.value.copy { gpsMode = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.update_interval),
summary = stringResource(id = R.string.config_position_gps_update_interval_summary),
value = formState.value.gpsUpdateInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { gpsUpdateInterval = it } },
)
}
item { PreferenceCategory(text = stringResource(R.string.position_flags)) }
item {
BitwisePreference(
title = stringResource(R.string.position_flags),
summary = stringResource(id = R.string.config_position_flags_summary),
value = formState.value.positionFlags,
enabled = state.connected,
items =
ConfigProtos.Config.PositionConfig.PositionFlags.entries
.filter {
it != PositionConfig.PositionFlags.UNSET && it != PositionConfig.PositionFlags.UNRECOGNIZED
}
.map { it.number to it.name },
onItemSelected = { formState.value = formState.value.copy { positionFlags = it } },
)
}
item { HorizontalDivider() }
item { PreferenceCategory(text = stringResource(R.string.advanced_device_gps)) }
item {
EditTextPreference(
title = stringResource(R.string.gps_receive_gpio),
value = formState.value.rxGpio,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { rxGpio = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.gps_transmit_gpio),
value = formState.value.txGpio,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { txGpio = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.gps_en_gpio),
value = formState.value.gpsEnGpio,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { gpsEnGpio = it } },
)
}
}
}

View file

@ -1,159 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun PowerConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val powerConfig = state.radioConfig.power
val formState = rememberConfigState(initialValue = powerConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.power),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { power = it }
viewModel.setConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.power_config)) }
item {
SwitchPreference(
title = stringResource(R.string.enable_power_saving_mode),
summary = stringResource(id = R.string.config_power_is_power_saving_summary),
checked = formState.value.isPowerSaving,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { isPowerSaving = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.shutdown_on_power_loss),
checked = formState.value.onBatteryShutdownAfterSecs > 0,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy { onBatteryShutdownAfterSecs = if (it) 3600 else 0 }
},
)
}
if (formState.value.onBatteryShutdownAfterSecs > 0) {
item {
EditTextPreference(
title = stringResource(R.string.shutdown_on_battery_delay_seconds),
value = formState.value.onBatteryShutdownAfterSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { onBatteryShutdownAfterSecs = it } },
)
}
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.adc_multiplier_override),
checked = formState.value.adcMultiplierOverride > 0f,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy { adcMultiplierOverride = if (it) 1.0f else 0.0f }
},
)
}
if (formState.value.adcMultiplierOverride > 0f) {
item {
EditTextPreference(
title = stringResource(R.string.adc_multiplier_override_ratio),
value = formState.value.adcMultiplierOverride,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { adcMultiplierOverride = it } },
)
}
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.wait_for_bluetooth_duration_seconds),
value = formState.value.waitBluetoothSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { waitBluetoothSecs = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.super_deep_sleep_duration_seconds),
value = formState.value.sdsSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { sdsSecs = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.minimum_wake_time_seconds),
value = formState.value.minWakeSecs,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { minWakeSecs = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.battery_ina_2xx_i2c_address),
value = formState.value.deviceBatteryInaAddress,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { deviceBatteryInaAddress = it } },
)
}
}
}

View file

@ -1,88 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.settings.radio.ResponseState
import com.google.protobuf.MessageLite
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.PreferenceFooter
@Composable
fun <T : MessageLite> RadioConfigScreenList(
title: String,
onBack: () -> Unit,
responseState: ResponseState<Any>,
onDismissPacketResponse: () -> Unit,
configState: ConfigState<T>,
enabled: Boolean,
onSave: (T) -> Unit,
content: LazyListScope.() -> Unit,
) {
val focusManager = LocalFocusManager.current
if (responseState.isWaiting()) {
PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
}
Scaffold(
topBar = {
MainAppBar(
title = title,
canNavigateUp = true,
onNavigateUp = onBack,
ourNode = null,
showNodeChip = false,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
LazyColumn(modifier = Modifier.fillMaxSize().weight(1f), contentPadding = PaddingValues(16.dp)) {
content()
}
PreferenceFooter(
enabled = enabled && configState.isDirty,
negativeText = stringResource(R.string.discard_changes),
onNegativeClicked = {
focusManager.clearFocus()
configState.reset()
},
positiveText = stringResource(R.string.save_changes),
onPositiveClicked = {
focusManager.clearFocus()
onSave(configState.value)
},
)
}
}
}

View file

@ -1,88 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun RangeTestConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val rangeTestConfig = state.moduleConfig.rangeTest
val formState = rememberConfigState(initialValue = rangeTestConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.range_test),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { rangeTest = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.range_test_config)) }
item {
SwitchPreference(
title = stringResource(R.string.range_test_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.sender_message_interval_seconds),
value = formState.value.sender,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { sender = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.save_csv_in_storage_esp32_only),
checked = formState.value.save,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { save = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,95 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun RemoteHardwareConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val remoteHardwareConfig = state.moduleConfig.remoteHardware
val formState = rememberConfigState(initialValue = remoteHardwareConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.remote_hardware),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { remoteHardware = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.remote_hardware_config)) }
item {
SwitchPreference(
title = stringResource(R.string.remote_hardware_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.allow_undefined_pin_access),
checked = formState.value.allowUndefinedPinAccess,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { allowUndefinedPinAccess = it } },
)
}
item { HorizontalDivider() }
item {
EditListPreference(
title = stringResource(R.string.available_pins),
list = formState.value.availablePinsList,
maxCount = 4, // available_pins max_count:4
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = { list ->
formState.value =
formState.value.copy {
availablePins.clear()
availablePins.addAll(list)
}
},
)
}
}
}

View file

@ -1,296 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ConfigProtos.Config.SecurityConfig
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.node.NodeActionButton
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.google.protobuf.ByteString
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.model.util.toByteString
import org.meshtastic.core.strings.R
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.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
import java.security.SecureRandom
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SecurityConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val node by viewModel.destNode.collectAsStateWithLifecycle()
val securityConfig = state.radioConfig.security
val formState = rememberConfigState(initialValue = securityConfig)
var publicKey by rememberSaveable { mutableStateOf(formState.value.publicKey) }
LaunchedEffect(formState.value.privateKey) {
if (formState.value.privateKey != securityConfig.privateKey) {
publicKey = "".toByteString()
} else if (formState.value.privateKey == securityConfig.privateKey) {
publicKey = securityConfig.publicKey
}
}
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri, securityConfig) }
}
}
var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) }
PrivateKeyRegenerateDialog(
showKeyGenerationDialog = showKeyGenerationDialog,
onConfirm = {
formState.value = it
showKeyGenerationDialog = false
val config = config { security = formState.value }
viewModel.setConfig(config)
},
onDismiss = { showKeyGenerationDialog = false },
)
var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) }
if (showEditSecurityConfigDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.export_keys)) },
text = { Text(text = stringResource(R.string.export_keys_confirmation)) },
onDismissRequest = { showEditSecurityConfigDialog = false },
confirmButton = {
TextButton(
onClick = {
showEditSecurityConfigDialog = false
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.shortName}_keys_${System.currentTimeMillis()}.json",
)
}
exportConfigLauncher.launch(intent)
},
) {
Text(stringResource(R.string.okay))
}
},
)
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.security),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { security = it }
viewModel.setConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.direct_message_key)) }
item {
EditBase64Preference(
title = stringResource(R.string.public_key),
summary = stringResource(id = R.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 { this.publicKey = it }
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.publicKey.encodeToString()) },
)
}
item {
EditBase64Preference(
title = stringResource(R.string.private_key),
summary = stringResource(id = R.string.config_security_private_key),
value = formState.value.privateKey,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size() == 32) {
formState.value = formState.value.copy { privateKey = it }
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.privateKey.encodeToString()) },
)
}
item {
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(R.string.regenerate_private_key),
enabled = state.connected,
icon = Icons.TwoTone.Warning,
onClick = { showKeyGenerationDialog = true },
)
}
item {
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(R.string.export_keys),
enabled = state.connected,
icon = Icons.TwoTone.Warning,
onClick = { showEditSecurityConfigDialog = true },
)
}
item { PreferenceCategory(text = stringResource(R.string.admin_keys)) }
item {
EditListPreference(
title = stringResource(R.string.admin_key),
summary = stringResource(id = R.string.config_security_admin_key),
list = formState.value.adminKeyList,
maxCount = 3,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = {
formState.value =
formState.value.copy {
adminKey.clear()
adminKey.addAll(it)
}
},
)
}
item { PreferenceCategory(text = stringResource(R.string.logs)) }
item {
SwitchPreference(
title = stringResource(R.string.serial_console),
summary = stringResource(id = R.string.config_security_serial_enabled),
checked = formState.value.serialEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { serialEnabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.debug_log_api_enabled),
summary = stringResource(id = R.string.config_security_debug_log_api_enabled),
checked = formState.value.debugLogApiEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { debugLogApiEnabled = it } },
)
}
item { HorizontalDivider() }
item { PreferenceCategory(text = stringResource(R.string.administration)) }
item {
SwitchPreference(
title = stringResource(R.string.managed_mode),
summary = stringResource(id = R.string.config_security_is_managed),
checked = formState.value.isManaged,
enabled = state.connected && formState.value.adminKeyCount > 0,
onCheckedChange = { formState.value = formState.value.copy { isManaged = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.legacy_admin_channel),
checked = formState.value.adminChannelEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { adminChannelEnabled = it } },
)
}
item { HorizontalDivider() }
}
}
@Suppress("MagicNumber")
@Composable
fun PrivateKeyRegenerateDialog(
showKeyGenerationDialog: Boolean,
onConfirm: (SecurityConfig) -> Unit,
onDismiss: () -> Unit = {},
) {
if (showKeyGenerationDialog) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.regenerate_private_key)) },
text = { Text(text = stringResource(R.string.regenerate_keys_confirmation)) },
confirmButton = {
TextButton(
onClick = {
val securityInput =
SecurityConfig.newBuilder()
.apply {
clearPrivateKey()
clearPublicKey()
// 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()
privateKey = ByteString.copyFrom(f)
}
.build()
onConfirm(securityInput)
},
) {
Text(stringResource(R.string.okay))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
}

View file

@ -1,148 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig.SerialConfig
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun SerialConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val serialConfig = state.moduleConfig.serial
val formState = rememberConfigState(initialValue = serialConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.serial),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { serial = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.serial_config)) }
item {
SwitchPreference(
title = stringResource(R.string.serial_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.echo_enabled),
checked = formState.value.echo,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { echo = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = "RX",
value = formState.value.rxd,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { rxd = it } },
)
}
item {
EditTextPreference(
title = "TX",
value = formState.value.txd,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { txd = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.serial_baud_rate),
enabled = state.connected,
items =
SerialConfig.Serial_Baud.entries
.filter { it != SerialConfig.Serial_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.baud,
onItemSelected = { formState.value = formState.value.copy { baud = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.timeout),
value = formState.value.timeout,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { timeout = it } },
)
}
item {
DropDownPreference(
title = stringResource(R.string.serial_mode),
enabled = state.connected,
items =
SerialConfig.Serial_Mode.entries
.filter { it != SerialConfig.Serial_Mode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.mode,
onItemSelected = { formState.value = formState.value.copy { mode = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.override_console_serial_port),
checked = formState.value.overrideConsoleSerialPort,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { overrideConsoleSerialPort = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,118 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun StoreForwardConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val storeForwardConfig = state.moduleConfig.storeForward
val formState = rememberConfigState(initialValue = storeForwardConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.store_forward),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { storeForward = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.store_forward_config)) }
item {
SwitchPreference(
title = stringResource(R.string.store_forward_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.heartbeat),
checked = formState.value.heartbeat,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { heartbeat = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.number_of_records),
value = formState.value.records,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { records = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.history_return_max),
value = formState.value.historyReturnMax,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { historyReturnMax = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.history_return_window),
value = formState.value.historyReturnWindow,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { historyReturnWindow = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.server),
checked = formState.value.isServer,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { isServer = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,168 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun TelemetryConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val telemetryConfig = state.moduleConfig.telemetry
val formState = rememberConfigState(initialValue = telemetryConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.telemetry),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { telemetry = it }
viewModel.setModuleConfig(config)
},
) {
item { PreferenceCategory(text = stringResource(R.string.telemetry_config)) }
item {
SwitchPreference(
title = stringResource(R.string.device_telemetry_enabled),
summary = stringResource(R.string.device_telemetry_enabled_summary),
checked = formState.value.deviceTelemetryEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { deviceTelemetryEnabled = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.device_metrics_update_interval_seconds),
value = formState.value.deviceUpdateInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { deviceUpdateInterval = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.environment_metrics_update_interval_seconds),
value = formState.value.environmentUpdateInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { environmentUpdateInterval = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.environment_metrics_module_enabled),
checked = formState.value.environmentMeasurementEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { environmentMeasurementEnabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.environment_metrics_on_screen_enabled),
checked = formState.value.environmentScreenEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { environmentScreenEnabled = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.environment_metrics_use_fahrenheit),
checked = formState.value.environmentDisplayFahrenheit,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { environmentDisplayFahrenheit = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.air_quality_metrics_module_enabled),
checked = formState.value.airQualityEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { airQualityEnabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.air_quality_metrics_update_interval_seconds),
value = formState.value.airQualityInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { airQualityInterval = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.power_metrics_module_enabled),
checked = formState.value.powerMeasurementEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { powerMeasurementEnabled = it } },
)
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.power_metrics_update_interval_seconds),
value = formState.value.powerUpdateInterval,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { powerUpdateInterval = it } },
)
}
item {
SwitchPreference(
title = stringResource(R.string.power_metrics_on_screen_enabled),
checked = formState.value.powerScreenEnabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { powerScreenEnabled = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,132 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PreferenceCategory
import org.meshtastic.core.ui.component.RegularPreference
import org.meshtastic.core.ui.component.SwitchPreference
@Composable
fun UserConfigScreen(navController: NavController, viewModel: RadioConfigViewModel = hiltViewModel()) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val userConfig = state.userConfig
val formState = rememberConfigState(initialValue = userConfig)
val firmwareVersion = DeviceVersion(state.metadata?.firmwareVersion ?: "")
val validLongName = formState.value.longName.isNotBlank()
val validShortName = formState.value.shortName.isNotBlank()
val validNames = validLongName && validShortName
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(id = R.string.user),
onBack = { navController.popBackStack() },
configState = formState,
enabled = state.connected && validNames,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = viewModel::setOwner,
) {
item { PreferenceCategory(text = stringResource(R.string.user_config)) }
item {
RegularPreference(title = stringResource(R.string.node_id), subtitle = formState.value.id, onClick = {})
}
item { HorizontalDivider() }
item {
EditTextPreference(
title = stringResource(R.string.long_name),
value = formState.value.longName,
maxSize = 39, // long_name max_size:40
enabled = state.connected,
isError = !validLongName,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { longName = it } },
)
}
item {
EditTextPreference(
title = stringResource(R.string.short_name),
value = formState.value.shortName,
maxSize = 4, // short_name max_size:5
enabled = state.connected,
isError = !validShortName,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { shortName = it } },
)
}
item {
RegularPreference(
title = stringResource(R.string.hardware_model),
subtitle = formState.value.hwModel.name,
onClick = {},
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.unmessageable),
summary = stringResource(R.string.unmonitored_or_infrastructure),
checked =
formState.value.isUnmessagable ||
(firmwareVersion < DeviceVersion("2.6.9") && formState.value.role.isUnmessageableRole()),
enabled = formState.value.hasIsUnmessagable() || firmwareVersion >= DeviceVersion("2.6.9"),
onCheckedChange = { formState.value = formState.value.copy { isUnmessagable = it } },
)
}
item { HorizontalDivider() }
item {
SwitchPreference(
title = stringResource(R.string.licensed_amateur_radio),
summary = stringResource(R.string.licensed_amateur_radio_text),
checked = formState.value.isLicensed,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { isLicensed = it } },
)
}
item { HorizontalDivider() }
}
}

View file

@ -1,63 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.ui.settings.radio.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun WarningDialog(
icon: ImageVector? = Icons.Rounded.Warning,
title: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
icon = { icon?.let { Icon(imageVector = it, contentDescription = null) } },
title = { Text(text = title) },
dismissButton = { TextButton(onClick = { onDismiss() }) { Text(stringResource(R.string.cancel)) } },
confirmButton = {
Button(
onClick = {
onDismiss()
onConfirm()
},
) {
Text(stringResource(R.string.send))
}
},
)
}
@Preview
@Composable
private fun WarningDialogPreview() {
AppTheme { WarningDialog(title = "Factory Reset?", onDismiss = {}, onConfirm = {}) }
}

View file

@ -95,12 +95,7 @@ import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshUtilApplication.Companion.analytics
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.copy
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import com.geeksville.mesh.ui.settings.radio.components.ChannelSelection
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -118,6 +113,11 @@ import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.PreferenceFooter
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.ChannelSelection
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import timber.log.Timber
/**

View file

@ -28,7 +28,6 @@ import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.channelSet
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.getChannelList
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@ -37,6 +36,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.proto.getChannelList
import org.meshtastic.core.service.ServiceRepository
import timber.log.Timber
import javax.inject.Inject

View file

@ -1,81 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.util
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import org.meshtastic.core.strings.R
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.util.Locale
object LanguageUtils {
const val SYSTEM_DEFAULT = "zz"
fun setAppLocale(languageTag: String) {
AppCompatDelegate.setApplicationLocales(
if (languageTag == SYSTEM_DEFAULT) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(languageTag)
},
)
}
/** Using locales_config.xml, maps language tags to their localized language names (e.g.: "en" -> "English") */
@Suppress("CyclomaticComplexMethod")
fun Context.getLanguageMap(): Map<String, String> {
val languageTags = buildList {
add(SYSTEM_DEFAULT)
try {
resources.getXml(com.geeksville.mesh.R.xml.locales_config).use { parser ->
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") {
val languageTag =
parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name")
languageTag?.let { add(it) }
}
parser.next()
}
}
} catch (e: Exception) {
Timber.e("Error parsing locale_config.xml: ${e.message}")
}
}
return languageTags.associateWith { languageTag ->
when (languageTag) {
SYSTEM_DEFAULT -> getString(R.string.preferences_system_default)
"fr-HT" -> getString(R.string.fr_HT)
"pt-BR" -> getString(R.string.pt_BR)
"zh-CN" -> getString(R.string.zh_CN)
"zh-TW" -> getString(R.string.zh_TW)
else -> {
Locale.forLanguageTag(languageTag).let { locale ->
locale.getDisplayLanguage(locale).replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase(locale) else char.toString()
}
}
}
}
}
}
}

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.util
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@Suppress("SpreadOperator")
sealed class UiText {
data class DynamicString(val value: String) : UiText()
class StringResource(@StringRes val resId: Int, vararg val args: Any) : UiText()
@Composable
fun asString(): String {
return when (this) {
is DynamicString -> value
is StringResource -> stringResource(resId, *args)
}
}
fun asString(context: Context): String {
return when (this) {
is DynamicString -> value
is StringResource -> context.getString(resId, *args)
}
}
}