Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
package com.geeksville.mesh.navigation

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
Expand All @@ -32,7 +35,11 @@ import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.navigation.ConfigRoute
Expand Down Expand Up @@ -76,6 +83,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
settingsViewModel = hiltViewModel(parentEntry),
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
Expand All @@ -84,10 +92,39 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
}
},
) {
navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
navController.navigate(it)
}
}

composable<SettingsRoutes.DeviceConfiguration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
DeviceConfigurationScreen(
viewModel = hiltViewModel(parentEntry),
onBack = navController::popBackStack,
onNavigate = { route -> navController.navigate(route) },
)
}

composable<SettingsRoutes.ModuleConfiguration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
val settingsViewModel: SettingsViewModel = hiltViewModel(parentEntry)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = hiltViewModel(parentEntry),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = navController::popBackStack,
onNavigate = { route -> navController.navigate(route) },
)
}

composable<SettingsRoutes.Administration> { backStackEntry ->
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
AdministrationScreen(viewModel = hiltViewModel(parentEntry), onBack = navController::popBackStack)
}

composable<SettingsRoutes.CleanNodeDb>(
deepLinks =
listOf(
Expand All @@ -104,6 +141,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)

Expand Down Expand Up @@ -133,6 +171,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -35,7 +36,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.core.simpleSharedFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
Expand Down Expand Up @@ -82,10 +82,10 @@ constructor(
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()

private val _receivedData = simpleSharedFlow<ByteArray>()
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
val receivedData: SharedFlow<ByteArray> = _receivedData

private val _connectionError = simpleSharedFlow<BleError>()
private val _connectionError = MutableSharedFlow<BleError>(extraBufferCapacity = 64)
val connectionError: SharedFlow<BleError> = _connectionError.asSharedFlow()

// Thread-safe StateFlow for tracking device address changes
Expand Down Expand Up @@ -371,7 +371,7 @@ constructor(
serviceScope.handledLaunch { handleSendToRadio(a) }
}

private val _meshActivity = simpleSharedFlow<MeshActivity>()
private val _meshActivity = MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64)
val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()

private fun emitSendActivity() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ constructor(
radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
}

fun getCachedLocalConfig(): LocalConfig = localConfig.value

fun getCachedChannelSet(): ChannelSet = channelSet.value

@VisibleForTesting internal constructor() : this(null, null, null, null)

fun getCurrentPacketId(): Long = currentPacketId.get()
Expand Down
8 changes: 2 additions & 6 deletions app/src/main/java/com/geeksville/mesh/service/MeshService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
Expand Down Expand Up @@ -249,9 +247,7 @@ class MeshService : Service() {

override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }

override fun getConfig(): ByteArray = toRemoteExceptions {
runBlocking { radioConfigRepository.localConfigFlow.first().encode() }
}
override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() }

override fun setConfig(payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetConfig(payload, myNodeNum)
Expand Down Expand Up @@ -310,7 +306,7 @@ class MeshService : Service() {
}

override fun getChannelSet(): ByteArray = toRemoteExceptions {
runBlocking { radioConfigRepository.channelSetFlow.first().encode() }
commandSender.getCachedChannelSet().encode()
}

override fun getNodes(): List<NodeInfo> = nodeManager.getNodes()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
Expand All @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
Expand Down Expand Up @@ -72,7 +74,7 @@ class BleConnection(
*
* @param p The peripheral to connect to.
*/
suspend fun connect(p: Peripheral) {
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
stateJob?.cancel()
peripheral = p

Expand Down Expand Up @@ -156,7 +158,7 @@ class BleConnection(
}

/** Disconnects from the current peripheral. */
suspend fun disconnect() {
suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
*/
package org.meshtastic.core.common.util

import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject

Expand All @@ -26,15 +29,31 @@ import javax.inject.Inject
* for ensuring that only one operation of a certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference<Job?>(null)
private val job = AtomicReference<Job?>()

/**
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
* to ensure exceptions are reported.
*
* @param timeoutMs Optional timeout in milliseconds. If > 0, the [block] is wrapped in [withTimeout] so that
* indefinitely-suspended coroutines (e.g. blocked DataStore reads) throw [TimeoutCancellationException] instead
* of hanging silently.
*/
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
fun launch(scope: CoroutineScope, timeoutMs: Long = 0, block: suspend CoroutineScope.() -> Unit) {
cancel()
val newJob = scope.handledLaunch(block = block)
val newJob =
scope.handledLaunch {
if (timeoutMs > 0) {
try {
withTimeout(timeoutMs, block)
} catch (e: TimeoutCancellationException) {
Logger.w { "SequentialJob timed out after ${timeoutMs}ms" }
throw e
}
} else {
block()
}
}
job.set(newJob)

newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand All @@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package org.meshtastic.core.data.datasource

import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -54,7 +53,8 @@ class SwitchingNodeInfoReadDataSource @Inject constructor(private val dbManager:
}

override suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) }
dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } ?: emptyList()

override suspend fun getUnknownNodes(): List<NodeEntity> = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() }
override suspend fun getUnknownNodes(): List<NodeEntity> =
dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } ?: emptyList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,33 +33,43 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {

override suspend fun upsert(node: NodeEntity) =
override suspend fun upsert(node: NodeEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
}

override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) =
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
}

override suspend fun clearNodeDB(preserveFavorites: Boolean) =
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
}

override suspend fun clearMyNodeInfo() =
override suspend fun clearMyNodeInfo() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
}

override suspend fun deleteNode(num: Int) =
override suspend fun deleteNode(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
}

override suspend fun deleteNodes(nodeNums: List<Int>) =
override suspend fun deleteNodes(nodeNums: List<Int>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
}

override suspend fun deleteMetadata(num: Int) =
override suspend fun deleteMetadata(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
}

override suspend fun upsert(metadata: MetadataEntity) =
override suspend fun upsert(metadata: MetadataEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
}

override suspend fun setNodeNotes(num: Int, notes: String) =
override suspend fun setNodeNotes(num: Int, notes: String) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
}

override suspend fun backfillDenormalizedNames() =
override suspend fun backfillDenormalizedNames() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -44,6 +45,7 @@ import javax.inject.Singleton
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
Expand Down Expand Up @@ -114,8 +116,15 @@ class DatabaseManager @Inject constructor(private val app: Application, private
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}

private val limitedIo = dispatchers.io.limitedParallelism(4)

/** Execute [block] with the current DB instance. */
inline fun <T> withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
val active = _currentDb.value?.openHelper?.databaseName ?: return@withContext null
markLastUsed(active)
val db = _currentDb.value ?: return@withContext null // Use the cached current DB
block(db)
}

/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ object SettingsRoutes {

@Serializable data class Settings(val destNum: Int? = null) : Route

@Serializable data object DeviceConfiguration : Route

@Serializable data object ModuleConfiguration : Route

@Serializable data object Administration : Route

// region radio Config Routes

@Serializable data object User : Route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@
<string name="direct_message">Direct Message</string>
<string name="nodedb_reset">NodeDB reset</string>
<string name="delivery_confirmed">Delivery confirmed</string>
<string name="delivery_confirmed_reboot_warning">Your device may disconnect and reboot while settings are applied.</string>
<string name="error">Error</string>
<string name="ignore">Ignore</string>
<string name="remove_ignored">Remove from ignored</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class ServiceRepository @Inject constructor() {
}
}

private val _meshPacketFlow = MutableSharedFlow<MeshPacket>()
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>(extraBufferCapacity = 64)
val meshPacketFlow: SharedFlow<MeshPacket>
get() = _meshPacketFlow

Expand Down
Loading
Loading