mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(radioconfig): add clean node database screen (#2592)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
7c561ae4f8
commit
3646438a62
8 changed files with 588 additions and 321 deletions
|
|
@ -44,55 +44,59 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NodeRepository @Inject constructor(
|
||||
@Suppress("TooManyFunctions")
|
||||
class NodeRepository
|
||||
@Inject
|
||||
constructor(
|
||||
processLifecycle: Lifecycle,
|
||||
private val nodeInfoDao: NodeInfoDao,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
// hardware info about our local device (can be null)
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeInfoDao.getMyNodeInfo()
|
||||
.flowOn(dispatchers.io)
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?> =
|
||||
nodeInfoDao
|
||||
.getMyNodeInfo()
|
||||
.flowOn(dispatchers.io)
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
|
||||
|
||||
// our node info
|
||||
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
|
||||
val ourNodeInfo: StateFlow<Node?> get() = _ourNodeInfo
|
||||
val ourNodeInfo: StateFlow<Node?>
|
||||
get() = _ourNodeInfo
|
||||
|
||||
// The unique userId of our node
|
||||
private val _myId = MutableStateFlow<String?>(null)
|
||||
val myId: StateFlow<String?> get() = _myId
|
||||
val myId: StateFlow<String?>
|
||||
get() = _myId
|
||||
|
||||
fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum()
|
||||
.map { map -> map.mapValues { (_, it) -> it.toEntity() } }
|
||||
fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
|
||||
|
||||
// A map from nodeNum to Node
|
||||
val nodeDBbyNum: StateFlow<Map<Int, Node>> = nodeInfoDao.nodeDBbyNum()
|
||||
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
|
||||
.onEach {
|
||||
val ourNodeInfo = it.values.firstOrNull()
|
||||
_ourNodeInfo.value = ourNodeInfo
|
||||
_myId.value = ourNodeInfo?.user?.id
|
||||
}
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
|
||||
nodeInfoDao
|
||||
.nodeDBbyNum()
|
||||
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
|
||||
.onEach {
|
||||
val ourNodeInfo = it.values.firstOrNull()
|
||||
_ourNodeInfo.value = ourNodeInfo
|
||||
_myId.value = ourNodeInfo?.user?.id
|
||||
}
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
|
||||
?: Node(
|
||||
num = DataPacket.idToDefaultNodeNum(userId) ?: 0,
|
||||
user = getUser(userId),
|
||||
)
|
||||
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
|
||||
|
||||
fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
|
||||
fun getUser(userId: String): MeshProtos.User =
|
||||
nodeDBbyNum.value.values.find { it.user.id == userId }?.user
|
||||
?: MeshProtos.User.newBuilder()
|
||||
.setId(userId)
|
||||
.setLongName("Meshtastic ${userId.takeLast(n = 4)}")
|
||||
.setShortName(userId.takeLast(n = 4))
|
||||
.setHwModel(MeshProtos.HardwareModel.UNSET)
|
||||
.build()
|
||||
fun getUser(userId: String): MeshProtos.User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
|
||||
?: MeshProtos.User.newBuilder()
|
||||
.setId(userId)
|
||||
.setLongName("Meshtastic ${userId.takeLast(n = 4)}")
|
||||
.setShortName(userId.takeLast(n = 4))
|
||||
.setHwModel(MeshProtos.HardwareModel.UNSET)
|
||||
.build()
|
||||
|
||||
fun getNodes(
|
||||
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
|
||||
|
|
@ -100,21 +104,19 @@ class NodeRepository @Inject constructor(
|
|||
includeUnknown: Boolean = true,
|
||||
onlyOnline: Boolean = false,
|
||||
onlyDirect: Boolean = false,
|
||||
) = nodeInfoDao.getNodes(
|
||||
sort = sort.sqlValue,
|
||||
filter = filter,
|
||||
includeUnknown = includeUnknown,
|
||||
hopsAwayMax = if (onlyDirect) 0 else -1,
|
||||
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
|
||||
).mapLatest { list ->
|
||||
list.map {
|
||||
it.toModel()
|
||||
}
|
||||
}.flowOn(dispatchers.io).conflate()
|
||||
) = nodeInfoDao
|
||||
.getNodes(
|
||||
sort = sort.sqlValue,
|
||||
filter = filter,
|
||||
includeUnknown = includeUnknown,
|
||||
hopsAwayMax = if (onlyDirect) 0 else -1,
|
||||
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
|
||||
)
|
||||
.mapLatest { list -> list.map { it.toModel() } }
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
|
||||
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) {
|
||||
nodeInfoDao.upsert(node)
|
||||
}
|
||||
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) }
|
||||
|
||||
suspend fun installNodeDB(mi: MyNodeEntity, nodes: List<NodeEntity>) = withContext(dispatchers.io) {
|
||||
nodeInfoDao.clearMyNodeInfo()
|
||||
|
|
@ -122,24 +124,32 @@ class NodeRepository @Inject constructor(
|
|||
nodeInfoDao.putAll(nodes)
|
||||
}
|
||||
|
||||
suspend fun clearNodeDB() = withContext(dispatchers.io) {
|
||||
nodeInfoDao.clearNodeInfo()
|
||||
}
|
||||
suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoDao.clearNodeInfo() }
|
||||
|
||||
suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
|
||||
nodeInfoDao.deleteNode(num)
|
||||
nodeInfoDao.deleteMetadata(num)
|
||||
}
|
||||
|
||||
suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) {
|
||||
nodeInfoDao.upsert(metadata)
|
||||
suspend fun deleteNodes(nodeNums: List<Int>) = withContext(dispatchers.io) {
|
||||
nodeInfoDao.deleteNodes(nodeNums)
|
||||
nodeNums.forEach { nodeInfoDao.deleteMetadata(it) }
|
||||
}
|
||||
|
||||
val onlineNodeCount: Flow<Int> = nodeInfoDao.nodeDBbyNum().mapLatest { map ->
|
||||
map.values.count { it.node.lastHeard > onlineTimeThreshold() }
|
||||
}.flowOn(dispatchers.io).conflate()
|
||||
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
|
||||
withContext(dispatchers.io) { nodeInfoDao.getNodesOlderThan(lastHeard) }
|
||||
|
||||
val totalNodeCount: Flow<Int> = nodeInfoDao.nodeDBbyNum().mapLatest { map ->
|
||||
map.values.count()
|
||||
}.flowOn(dispatchers.io).conflate()
|
||||
suspend fun getUnknownNodes(): List<NodeEntity> = withContext(dispatchers.io) { nodeInfoDao.getUnknownNodes() }
|
||||
|
||||
suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(metadata) }
|
||||
|
||||
val onlineNodeCount: Flow<Int> =
|
||||
nodeInfoDao
|
||||
.nodeDBbyNum()
|
||||
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
|
||||
val totalNodeCount: Flow<Int> =
|
||||
nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(dispatchers.io).conflate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,17 +50,18 @@ interface NodeInfoDao {
|
|||
if (nodeWithSamePK != null && nodeWithSamePK.num != node.num) {
|
||||
// This is the impersonation attempt we want to block.
|
||||
@Suppress("MaxLineLength")
|
||||
warn("NodeInfoDao: Blocking new node #${node.num} because its public key is already used by #${nodeWithSamePK.num}.")
|
||||
warn(
|
||||
"NodeInfoDao: Blocking new node #${node.num} because its public key is already used by #${nodeWithSamePK.num}.",
|
||||
)
|
||||
return null // ABORT
|
||||
}
|
||||
}
|
||||
// If we're here, the new node is safe to add.
|
||||
node
|
||||
node
|
||||
} else {
|
||||
// This is an update to an existing node.
|
||||
val keyMatch =
|
||||
existingNode.user.publicKey == node.user.publicKey || existingNode.user.publicKey.isEmpty
|
||||
if (keyMatch) {
|
||||
val keyMatch = existingNode.user.publicKey == node.user.publicKey || existingNode.user.publicKey.isEmpty
|
||||
if (keyMatch) {
|
||||
// Keys match, trust the incoming node completely.
|
||||
// This allows for legit nodeId changes etc.
|
||||
node
|
||||
|
|
@ -69,13 +70,15 @@ interface NodeInfoDao {
|
|||
// Log it, and create a NEW entity based on the EXISTING trusted one,
|
||||
// only updating dynamic data and setting the public key to EMPTY to signal a conflict.
|
||||
@Suppress("MaxLineLength")
|
||||
warn("NodeInfoDao: Received packet for #${node.num} with non-matching public key. Identity data ignored, key set to EMPTY.")
|
||||
warn(
|
||||
"NodeInfoDao: Received packet for #${node.num} with non-matching public key. Identity data ignored, key set to EMPTY.",
|
||||
)
|
||||
existingNode.copy(
|
||||
lastHeard = node.lastHeard,
|
||||
snr = node.snr,
|
||||
position = node.position,
|
||||
user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(),
|
||||
publicKey = ByteString.EMPTY
|
||||
publicKey = ByteString.EMPTY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -98,10 +101,16 @@ interface NodeInfoDao {
|
|||
ELSE 1
|
||||
END,
|
||||
last_heard DESC
|
||||
"""
|
||||
""",
|
||||
)
|
||||
@Transaction
|
||||
fun nodeDBbyNum(): Flow<Map<@MapColumn(columnName = "num") Int, NodeWithRelations>>
|
||||
fun nodeDBbyNum(): Flow<
|
||||
Map<
|
||||
@MapColumn(columnName = "num")
|
||||
Int,
|
||||
NodeWithRelations,
|
||||
>,
|
||||
>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
|
@ -125,7 +134,7 @@ interface NodeInfoDao {
|
|||
END,
|
||||
CASE
|
||||
WHEN :sort = 'last_heard' THEN last_heard * -1
|
||||
WHEN :sort = 'alpha' THEN UPPER(long_name)
|
||||
WHEN :sort = 'alpha' THEN UPPER(long_name)
|
||||
WHEN :sort = 'distance' THEN
|
||||
CASE
|
||||
WHEN latitude IS NULL OR longitude IS NULL OR
|
||||
|
|
@ -147,7 +156,7 @@ interface NodeInfoDao {
|
|||
ELSE 0
|
||||
END ASC,
|
||||
last_heard DESC
|
||||
"""
|
||||
""",
|
||||
)
|
||||
@Transaction
|
||||
fun getNodes(
|
||||
|
|
@ -178,8 +187,16 @@ interface NodeInfoDao {
|
|||
@Query("DELETE FROM nodes WHERE num=:num")
|
||||
fun deleteNode(num: Int)
|
||||
|
||||
@Upsert
|
||||
fun upsert(meta: MetadataEntity)
|
||||
@Query("DELETE FROM nodes WHERE num IN (:nodeNums)")
|
||||
fun deleteNodes(nodeNums: List<Int>)
|
||||
|
||||
@Query("SELECT * FROM nodes WHERE last_heard < :lastHeard")
|
||||
fun getNodesOlderThan(lastHeard: Int): List<NodeEntity>
|
||||
|
||||
@Query("SELECT * FROM nodes WHERE short_name IS NULL")
|
||||
fun getUnknownNodes(): List<NodeEntity>
|
||||
|
||||
@Upsert fun upsert(meta: MetadataEntity)
|
||||
|
||||
@Query("DELETE FROM metadata WHERE num=:num")
|
||||
fun deleteMetadata(num: Int)
|
||||
|
|
@ -191,8 +208,7 @@ interface NodeInfoDao {
|
|||
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
|
||||
fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
|
||||
|
||||
@Upsert
|
||||
fun doUpsert(node: NodeEntity)
|
||||
@Upsert fun doUpsert(node: NodeEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun doPutAll(nodes: List<NodeEntity>)
|
||||
|
|
|
|||
|
|
@ -155,6 +155,25 @@ data class NodeEntity(
|
|||
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
|
||||
}
|
||||
|
||||
fun toModel() = Node(
|
||||
num = num,
|
||||
user = user,
|
||||
position = position,
|
||||
snr = snr,
|
||||
rssi = rssi,
|
||||
lastHeard = lastHeard,
|
||||
deviceMetrics = deviceTelemetry.deviceMetrics,
|
||||
channel = channel,
|
||||
viaMqtt = viaMqtt,
|
||||
hopsAway = hopsAway,
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
environmentMetrics = environmentTelemetry.environmentMetrics,
|
||||
powerMetrics = powerTelemetry.powerMetrics,
|
||||
paxcounter = paxcounter,
|
||||
publicKey = publicKey ?: user.publicKey,
|
||||
)
|
||||
|
||||
fun toNodeInfo() = NodeInfo(
|
||||
num = num,
|
||||
user =
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import androidx.navigation.navigation
|
|||
import com.geeksville.mesh.MeshProtos.DeviceMetadata
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.radioconfig.CleanNodeDatabaseScreen
|
||||
import com.geeksville.mesh.ui.radioconfig.RadioConfigScreen
|
||||
import com.geeksville.mesh.ui.radioconfig.components.AmbientLightingConfigScreen
|
||||
import com.geeksville.mesh.ui.radioconfig.components.AudioConfigScreen
|
||||
|
|
@ -79,120 +80,88 @@ import com.geeksville.mesh.ui.radioconfig.components.UserConfigScreen
|
|||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed class RadioConfigRoutes {
|
||||
@Serializable
|
||||
data class RadioConfigGraph(val destNum: Int? = null) : Graph
|
||||
@Serializable data class RadioConfigGraph(val destNum: Int? = null) : Graph
|
||||
|
||||
@Serializable
|
||||
data class RadioConfig(val destNum: Int? = null) : Route
|
||||
@Serializable data class RadioConfig(val destNum: Int? = null) : Route
|
||||
|
||||
@Serializable
|
||||
data object User : Route
|
||||
@Serializable
|
||||
data object ChannelConfig : Route
|
||||
@Serializable data object User : Route
|
||||
|
||||
@Serializable
|
||||
data object Device : Route
|
||||
@Serializable data object ChannelConfig : Route
|
||||
|
||||
@Serializable
|
||||
data object Position : Route
|
||||
@Serializable data object Device : Route
|
||||
|
||||
@Serializable
|
||||
data object Power : Route
|
||||
@Serializable data object Position : Route
|
||||
|
||||
@Serializable
|
||||
data object Network : Route
|
||||
@Serializable data object Power : Route
|
||||
|
||||
@Serializable
|
||||
data object Display : Route
|
||||
@Serializable data object Network : Route
|
||||
|
||||
@Serializable
|
||||
data object LoRa : Route
|
||||
@Serializable data object Display : Route
|
||||
|
||||
@Serializable
|
||||
data object Bluetooth : Route
|
||||
@Serializable data object LoRa : Route
|
||||
|
||||
@Serializable
|
||||
data object Security : Route
|
||||
@Serializable data object Bluetooth : Route
|
||||
|
||||
@Serializable
|
||||
data object MQTT : Route
|
||||
@Serializable data object Security : Route
|
||||
|
||||
@Serializable
|
||||
data object Serial : Route
|
||||
@Serializable data object MQTT : Route
|
||||
|
||||
@Serializable
|
||||
data object ExtNotification : Route
|
||||
@Serializable data object Serial : Route
|
||||
|
||||
@Serializable
|
||||
data object StoreForward : Route
|
||||
@Serializable data object ExtNotification : Route
|
||||
|
||||
@Serializable
|
||||
data object RangeTest : Route
|
||||
@Serializable data object StoreForward : Route
|
||||
|
||||
@Serializable
|
||||
data object Telemetry : Route
|
||||
@Serializable data object RangeTest : Route
|
||||
|
||||
@Serializable
|
||||
data object CannedMessage : Route
|
||||
@Serializable data object Telemetry : Route
|
||||
|
||||
@Serializable
|
||||
data object Audio : Route
|
||||
@Serializable data object CannedMessage : Route
|
||||
|
||||
@Serializable
|
||||
data object RemoteHardware : Route
|
||||
@Serializable data object Audio : Route
|
||||
|
||||
@Serializable
|
||||
data object NeighborInfo : Route
|
||||
@Serializable data object RemoteHardware : Route
|
||||
|
||||
@Serializable
|
||||
data object AmbientLighting : Route
|
||||
@Serializable data object NeighborInfo : Route
|
||||
|
||||
@Serializable
|
||||
data object DetectionSensor : Route
|
||||
@Serializable data object AmbientLighting : Route
|
||||
|
||||
@Serializable
|
||||
data object Paxcounter : Route
|
||||
@Serializable data object DetectionSensor : Route
|
||||
|
||||
@Serializable data object Paxcounter : Route
|
||||
|
||||
@Serializable data object CleanNodeDb : Route
|
||||
}
|
||||
|
||||
fun getNavRouteFrom(routeName: String): Route? {
|
||||
return ConfigRoute.entries.find { it.name == routeName }?.route
|
||||
?: ModuleRoute.entries.find { it.name == routeName }?.route
|
||||
}
|
||||
fun getNavRouteFrom(routeName: String): Route? =
|
||||
ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route
|
||||
|
||||
fun NavGraphBuilder.radioConfigGraph(navController: NavHostController, uiViewModel: UIViewModel) {
|
||||
navigation<RadioConfigRoutes.RadioConfigGraph>(
|
||||
startDestination = RadioConfigRoutes.RadioConfig(),
|
||||
) {
|
||||
navigation<RadioConfigRoutes.RadioConfigGraph>(startDestination = RadioConfigRoutes.RadioConfig()) {
|
||||
composable<RadioConfigRoutes.RadioConfig> { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
RadioConfigScreen(
|
||||
uiViewModel = uiViewModel,
|
||||
viewModel = hiltViewModel(parentEntry)
|
||||
) {
|
||||
navController.navigate(it) {
|
||||
popUpTo(RadioConfigRoutes.RadioConfig()) {
|
||||
inclusive = false
|
||||
}
|
||||
val parentEntry =
|
||||
remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
RadioConfigScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) {
|
||||
navController.navigate(it) { popUpTo(RadioConfigRoutes.RadioConfig()) { inclusive = false } }
|
||||
}
|
||||
}
|
||||
composable<RadioConfigRoutes.CleanNodeDb> { CleanNodeDatabaseScreen() }
|
||||
configRoutes(navController)
|
||||
moduleRoutes(navController)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.configRoutes(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
private fun NavGraphBuilder.configRoutes(navController: NavHostController) {
|
||||
ConfigRoute.entries.forEach { configRoute ->
|
||||
composable(configRoute.route::class) { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
val parentEntry =
|
||||
remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
when (configRoute) {
|
||||
ConfigRoute.USER -> UserConfigScreen(hiltViewModel(parentEntry))
|
||||
ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry))
|
||||
|
|
@ -210,42 +179,31 @@ private fun NavGraphBuilder.configRoutes(
|
|||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun NavGraphBuilder.moduleRoutes(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
private fun NavGraphBuilder.moduleRoutes(navController: NavHostController) {
|
||||
ModuleRoute.entries.forEach { moduleRoute ->
|
||||
composable(moduleRoute.route::class) { backStackEntry ->
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
val parentEntry =
|
||||
remember(backStackEntry) {
|
||||
val parentRoute = backStackEntry.destination.parent!!.route!!
|
||||
navController.getBackStackEntry(parentRoute)
|
||||
}
|
||||
when (moduleRoute) {
|
||||
ModuleRoute.MQTT -> MQTTConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.SERIAL -> SerialConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreen(
|
||||
hiltViewModel(parentEntry)
|
||||
)
|
||||
ModuleRoute.EXT_NOTIFICATION -> ExternalNotificationConfigScreen(hiltViewModel(parentEntry))
|
||||
|
||||
ModuleRoute.STORE_FORWARD -> StoreForwardConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.CANNED_MESSAGE -> CannedMessageConfigScreen(
|
||||
hiltViewModel(parentEntry)
|
||||
)
|
||||
ModuleRoute.CANNED_MESSAGE -> CannedMessageConfigScreen(hiltViewModel(parentEntry))
|
||||
|
||||
ModuleRoute.AUDIO -> AudioConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.REMOTE_HARDWARE -> RemoteHardwareConfigScreen(
|
||||
hiltViewModel(parentEntry)
|
||||
)
|
||||
ModuleRoute.REMOTE_HARDWARE -> RemoteHardwareConfigScreen(hiltViewModel(parentEntry))
|
||||
|
||||
ModuleRoute.NEIGHBOR_INFO -> NeighborInfoConfigScreen(hiltViewModel(parentEntry))
|
||||
ModuleRoute.AMBIENT_LIGHTING -> AmbientLightingConfigScreen(
|
||||
hiltViewModel(parentEntry)
|
||||
)
|
||||
ModuleRoute.AMBIENT_LIGHTING -> AmbientLightingConfigScreen(hiltViewModel(parentEntry))
|
||||
|
||||
ModuleRoute.DETECTION_SENSOR -> DetectionSensorConfigScreen(
|
||||
hiltViewModel(parentEntry)
|
||||
)
|
||||
ModuleRoute.DETECTION_SENSOR -> DetectionSensorConfigScreen(hiltViewModel(parentEntry))
|
||||
|
||||
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(hiltViewModel(parentEntry))
|
||||
}
|
||||
|
|
@ -255,12 +213,7 @@ private fun NavGraphBuilder.moduleRoutes(
|
|||
|
||||
// Config (type = AdminProtos.AdminMessage.ConfigType)
|
||||
@Suppress("MagicNumber")
|
||||
enum class ConfigRoute(
|
||||
@StringRes val title: Int,
|
||||
val route: Route,
|
||||
val icon: ImageVector?,
|
||||
val type: Int = 0
|
||||
) {
|
||||
enum class ConfigRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) {
|
||||
USER(R.string.user, RadioConfigRoutes.User, Icons.Default.Person, 0),
|
||||
CHANNELS(R.string.channels, RadioConfigRoutes.ChannelConfig, Icons.AutoMirrored.Default.List, 0),
|
||||
DEVICE(R.string.device, RadioConfigRoutes.Device, Icons.Default.Router, 0),
|
||||
|
|
@ -287,48 +240,24 @@ enum class ConfigRoute(
|
|||
|
||||
// ModuleConfig (type = AdminProtos.AdminMessage.ModuleConfigType)
|
||||
@Suppress("MagicNumber")
|
||||
enum class ModuleRoute(
|
||||
@StringRes val title: Int,
|
||||
val route: Route,
|
||||
val icon: ImageVector?,
|
||||
val type: Int = 0
|
||||
) {
|
||||
enum class ModuleRoute(@StringRes val title: Int, val route: Route, val icon: ImageVector?, val type: Int = 0) {
|
||||
MQTT(R.string.mqtt, RadioConfigRoutes.MQTT, Icons.Default.Cloud, 0),
|
||||
SERIAL(R.string.serial, RadioConfigRoutes.Serial, Icons.Default.Usb, 1),
|
||||
EXT_NOTIFICATION(
|
||||
R.string.external_notification,
|
||||
RadioConfigRoutes.ExtNotification,
|
||||
Icons.Default.Notifications,
|
||||
2
|
||||
),
|
||||
STORE_FORWARD(
|
||||
R.string.store_forward,
|
||||
RadioConfigRoutes.StoreForward,
|
||||
Icons.AutoMirrored.Default.Forward,
|
||||
3
|
||||
),
|
||||
EXT_NOTIFICATION(R.string.external_notification, RadioConfigRoutes.ExtNotification, Icons.Default.Notifications, 2),
|
||||
STORE_FORWARD(R.string.store_forward, RadioConfigRoutes.StoreForward, Icons.AutoMirrored.Default.Forward, 3),
|
||||
RANGE_TEST(R.string.range_test, RadioConfigRoutes.RangeTest, Icons.Default.Speed, 4),
|
||||
TELEMETRY(R.string.telemetry, RadioConfigRoutes.Telemetry, Icons.Default.DataUsage, 5),
|
||||
CANNED_MESSAGE(
|
||||
R.string.canned_message,
|
||||
RadioConfigRoutes.CannedMessage,
|
||||
Icons.AutoMirrored.Default.Message,
|
||||
6
|
||||
),
|
||||
CANNED_MESSAGE(R.string.canned_message, RadioConfigRoutes.CannedMessage, Icons.AutoMirrored.Default.Message, 6),
|
||||
AUDIO(R.string.audio, RadioConfigRoutes.Audio, Icons.AutoMirrored.Default.VolumeUp, 7),
|
||||
REMOTE_HARDWARE(
|
||||
R.string.remote_hardware,
|
||||
RadioConfigRoutes.RemoteHardware,
|
||||
Icons.Default.SettingsRemote,
|
||||
8
|
||||
),
|
||||
REMOTE_HARDWARE(R.string.remote_hardware, RadioConfigRoutes.RemoteHardware, Icons.Default.SettingsRemote, 8),
|
||||
NEIGHBOR_INFO(R.string.neighbor_info, RadioConfigRoutes.NeighborInfo, Icons.Default.People, 9),
|
||||
AMBIENT_LIGHTING(R.string.ambient_lighting, RadioConfigRoutes.AmbientLighting, Icons.Default.LightMode, 10),
|
||||
DETECTION_SENSOR(R.string.detection_sensor, RadioConfigRoutes.DetectionSensor, Icons.Default.Sensors, 11),
|
||||
PAXCOUNTER(R.string.paxcounter, RadioConfigRoutes.Paxcounter, Icons.Default.PermScanWifi, 12),
|
||||
;
|
||||
|
||||
val bitfield: Int get() = 1 shl ordinal
|
||||
val bitfield: Int
|
||||
get() = 1 shl ordinal
|
||||
|
||||
companion object {
|
||||
fun filterExcludedFrom(metadata: DeviceMetadata?): List<ModuleRoute> = entries.filter {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
* 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.radioconfig
|
||||
|
||||
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.navigation.compose.hiltViewModel
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.ui.node.components.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),
|
||||
isThisNode = false,
|
||||
isConnected = false,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) } },
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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.radioconfig
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.database.NodeRepository
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
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 radioConfigRepository: RadioConfigRepository,
|
||||
) : 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 = radioConfigRepository.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ import com.geeksville.mesh.model.UIViewModel
|
|||
import com.geeksville.mesh.navigation.AdminRoute
|
||||
import com.geeksville.mesh.navigation.ConfigRoute
|
||||
import com.geeksville.mesh.navigation.ModuleRoute
|
||||
import com.geeksville.mesh.navigation.RadioConfigRoutes
|
||||
import com.geeksville.mesh.navigation.Route
|
||||
import com.geeksville.mesh.navigation.getNavRouteFrom
|
||||
import com.geeksville.mesh.ui.common.components.PreferenceCategory
|
||||
|
|
@ -87,22 +88,21 @@ fun RadioConfigScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
uiViewModel: UIViewModel = hiltViewModel(),
|
||||
onNavigate: (Route) -> Unit = {}
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
) {
|
||||
val node by viewModel.destNode.collectAsStateWithLifecycle()
|
||||
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val isLocal = node?.num == ourNode?.num
|
||||
val nodeName: String? = node?.user?.longName?.let {
|
||||
if (!isLocal) {
|
||||
"$it (" + stringResource(R.string.remote) + ")"
|
||||
} else {
|
||||
it
|
||||
val nodeName: String? =
|
||||
node?.user?.longName?.let {
|
||||
if (!isLocal) {
|
||||
"$it (" + stringResource(R.string.remote) + ")"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodeName?.let {
|
||||
uiViewModel.setTitle(it)
|
||||
}
|
||||
nodeName?.let { uiViewModel.setTitle(it) }
|
||||
|
||||
val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -128,28 +128,25 @@ fun RadioConfigScreen(
|
|||
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 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!!) }
|
||||
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) {
|
||||
title =
|
||||
if (deviceProfile != null) {
|
||||
stringResource(R.string.import_configuration)
|
||||
} else {
|
||||
stringResource(R.string.export_configuration)
|
||||
|
|
@ -161,18 +158,19 @@ fun RadioConfigScreen(
|
|||
viewModel.installProfile(it)
|
||||
} else {
|
||||
deviceProfile = it
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
putExtra(Intent.EXTRA_TITLE, "device_profile.cfg")
|
||||
}
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
putExtra(Intent.EXTRA_TITLE, "device_profile.cfg")
|
||||
}
|
||||
exportConfigLauncher.launch(intent)
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showEditDeviceProfileDialog = false
|
||||
deviceProfile = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -187,10 +185,11 @@ fun RadioConfigScreen(
|
|||
onImport = {
|
||||
viewModel.clearPacketResponse()
|
||||
deviceProfile = null
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
}
|
||||
val intent =
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
}
|
||||
importConfigLauncher.launch(intent)
|
||||
},
|
||||
onExport = {
|
||||
|
|
@ -198,44 +197,23 @@ fun RadioConfigScreen(
|
|||
deviceProfile = null
|
||||
showEditDeviceProfileDialog = true
|
||||
},
|
||||
onNavigate = onNavigate,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavCard(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
) {
|
||||
fun NavCard(title: String, enabled: Boolean, icon: ImageVector? = null, onClick: () -> Unit) {
|
||||
Card(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp)
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Icon(imageVector = icon, contentDescription = title, modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
Icons.AutoMirrored.TwoTone.KeyboardArrowRight, "trailingIcon",
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
)
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Icon(Icons.AutoMirrored.TwoTone.KeyboardArrowRight, "trailingIcon", modifier = Modifier.wrapContentSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -249,58 +227,48 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un
|
|||
onDismissRequest = {},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = stringResource(id = R.string.warning),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "${stringResource(title)}?\n"
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(text = "${stringResource(title)}?\n")
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = stringResource(id = R.string.warning),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { showDialog = false },
|
||||
) { Text(stringResource(R.string.cancel)) }
|
||||
TextButton(modifier = Modifier.weight(1f), onClick = { showDialog = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = {
|
||||
showDialog = false
|
||||
onClick()
|
||||
},
|
||||
) { Text(stringResource(R.string.send)) }
|
||||
) {
|
||||
Text(stringResource(R.string.send))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
enabled = enabled,
|
||||
onClick = { showDialog = true },
|
||||
) { Text(text = stringResource(title)) }
|
||||
Button(modifier = Modifier.fillMaxWidth().height(48.dp), enabled = enabled, onClick = { showDialog = true }) {
|
||||
Text(text = stringResource(title))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,6 +280,7 @@ private fun RadioConfigItemList(
|
|||
onRouteClick: (Enum<*>) -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
onExport: () -> Unit = {},
|
||||
onNavigate: (Route) -> Unit,
|
||||
) {
|
||||
val enabled = state.connected && !state.responseState.isWaiting()
|
||||
var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) }
|
||||
|
|
@ -322,26 +291,15 @@ private fun RadioConfigItemList(
|
|||
modules = ModuleRoute.filterExcludedFrom(state.metadata)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
LazyColumn(modifier = modifier, contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
item { PreferenceCategory(stringResource(R.string.radio_configuration)) }
|
||||
items(ConfigRoute.filterExcludedFrom(state.metadata)) {
|
||||
NavCard(
|
||||
title = stringResource(it.title),
|
||||
icon = it.icon,
|
||||
enabled = enabled
|
||||
) { onRouteClick(it) }
|
||||
NavCard(title = stringResource(it.title), icon = it.icon, enabled = enabled) { onRouteClick(it) }
|
||||
}
|
||||
|
||||
item { PreferenceCategory(stringResource(R.string.module_settings)) }
|
||||
items(modules) {
|
||||
NavCard(
|
||||
title = stringResource(it.title),
|
||||
icon = it.icon,
|
||||
enabled = enabled
|
||||
) { onRouteClick(it) }
|
||||
NavCard(title = stringResource(it.title), icon = it.icon, enabled = enabled) { onRouteClick(it) }
|
||||
}
|
||||
|
||||
if (state.isLocal) {
|
||||
|
|
@ -363,6 +321,15 @@ private fun RadioConfigItemList(
|
|||
}
|
||||
|
||||
items(AdminRoute.entries) { NavButton(it.title, enabled) { onRouteClick(it) } }
|
||||
|
||||
item {
|
||||
PreferenceCategory("Advanced")
|
||||
NavCard(
|
||||
title = stringResource(R.string.clean_node_database_title),
|
||||
enabled = enabled,
|
||||
onClick = { onNavigate(RadioConfigRoutes.CleanNodeDb) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,10 +337,7 @@ private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock
|
|||
private const val UNLOCK_TIMEOUT_SECONDS = 3 // Timeout in seconds to reset the click counter.
|
||||
|
||||
@Composable
|
||||
fun RadioConfigMenuActions(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun RadioConfigMenuActions(modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel()) {
|
||||
val context = LocalContext.current
|
||||
var counter by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(counter) {
|
||||
|
|
@ -388,22 +352,15 @@ fun RadioConfigMenuActions(
|
|||
counter++
|
||||
if (counter == UNLOCK_CLICK_COUNT) {
|
||||
viewModel.unlockExcludedModules()
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.modules_unlocked),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Toast.makeText(context, context.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun RadioSettingsScreenPreview() = AppTheme {
|
||||
RadioConfigItemList(
|
||||
RadioConfigState(isLocal = true, connected = true)
|
||||
)
|
||||
RadioConfigItemList(state = RadioConfigState(isLocal = true, connected = true), onNavigate = { _ -> })
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue