feat: settings rework (#4678)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-02 08:51:05 -06:00 committed by GitHub
parent b2b21e10e2
commit fdd07f893f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 941 additions and 306 deletions

View file

@ -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
@ -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
@ -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
@ -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()

View file

@ -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
@ -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) }

View file

@ -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
@ -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
@ -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()
}

View file

@ -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() } }
}
}

View file

@ -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
@ -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)
@ -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 {

View file

@ -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

View file

@ -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>

View file

@ -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