mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Modularize settings code (#3355)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
parent
4613a26c9d
commit
95ec4877df
75 changed files with 444 additions and 358 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = { _ -> },
|
||||
)
|
||||
}
|
||||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}) }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue