mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(service): harden KMP service layer — database init, connection reliability, handler decomposition (#4992)
This commit is contained in:
parent
e111b61e4e
commit
6af3ad6f0c
62 changed files with 3808 additions and 735 deletions
|
|
@ -17,8 +17,10 @@
|
|||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
|
|
@ -63,5 +65,7 @@ actual fun deleteDatabase(dbName: String) {
|
|||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates an Android DataStore for database preferences. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(produceFile = { ContextServices.app.preferencesDataStoreFile(name) })
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
|
||||
produceFile = { ContextServices.app.preferencesDataStoreFile(name) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ open class DatabaseManager(
|
|||
|
||||
private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName")
|
||||
|
||||
// Expose the DB cache limit as a reactive stream so UI can observe changes.
|
||||
override val cacheLimit: StateFlow<Int> =
|
||||
datastore.data
|
||||
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
|
||||
|
|
@ -81,26 +80,35 @@ open class DatabaseManager(
|
|||
}
|
||||
}
|
||||
|
||||
private val dbCache = mutableMapOf<String, MeshtasticDatabase>()
|
||||
|
||||
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
|
||||
|
||||
/**
|
||||
* The currently active database, built lazily on first access. Room's `onOpen` callback is itself lazy (not invoked
|
||||
* until the first query), so construction only allocates the builder and connection pool — actual I/O is deferred.
|
||||
*/
|
||||
override val currentDb: StateFlow<MeshtasticDatabase> =
|
||||
_currentDb
|
||||
.filterNotNull()
|
||||
.stateIn(
|
||||
managerScope,
|
||||
SharingStarted.Eagerly,
|
||||
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
|
||||
)
|
||||
.stateIn(managerScope, SharingStarted.Eagerly, getOrOpenDatabase(DatabaseConstants.DEFAULT_DB_NAME))
|
||||
|
||||
private val _currentAddress = MutableStateFlow<String?>(null)
|
||||
val currentAddress: StateFlow<String?> = _currentAddress
|
||||
|
||||
private val dbCache = mutableMapOf<String, MeshtasticDatabase>() // key = dbName
|
||||
|
||||
/** Initialize the active database for [address]. */
|
||||
suspend fun init(address: String?) {
|
||||
switchActiveDatabase(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cached [MeshtasticDatabase] or builds a new one for [dbName]. The caller must hold [mutex] when
|
||||
* modifying [dbCache] concurrently; however, this helper is also used from [currentDb]'s `initialValue` where the
|
||||
* mutex is not yet relevant (single-threaded construction).
|
||||
*/
|
||||
private fun getOrOpenDatabase(dbName: String): MeshtasticDatabase =
|
||||
dbCache.getOrPut(dbName) { getDatabaseBuilder(dbName).build() }
|
||||
|
||||
/** Switch active database to the one associated with [address]. Serialized via mutex. */
|
||||
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
|
||||
val dbName = buildDbName(address)
|
||||
|
|
@ -115,9 +123,11 @@ open class DatabaseManager(
|
|||
}
|
||||
|
||||
// Build/open Room DB off the main thread
|
||||
val db =
|
||||
dbCache[dbName]
|
||||
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
|
||||
val db = withContext(dispatchers.io) { getOrOpenDatabase(dbName) }
|
||||
|
||||
if (previousDbName != null && previousDbName != dbName) {
|
||||
closeCachedDatabase(previousDbName)
|
||||
}
|
||||
|
||||
_currentDb.value = db
|
||||
_currentAddress.value = address
|
||||
|
|
@ -134,6 +144,21 @@ open class DatabaseManager(
|
|||
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes and removes a cached database by name. Safe to call even if the database was already closed or not in the
|
||||
* cache. Does NOT delete the underlying file — the database can be re-opened on next access.
|
||||
*
|
||||
* On JVM/Desktop, Room KMP has no auto-close timeout (Android-only API), so idle databases hold open SQLite
|
||||
* connections (5 per WAL-mode DB) indefinitely until explicitly closed. This method is the primary mechanism for
|
||||
* releasing those connections when a database is no longer the active target.
|
||||
*/
|
||||
private fun closeCachedDatabase(dbName: String) {
|
||||
val removed = dbCache.remove(dbName) ?: return
|
||||
runCatching { removed.close() }
|
||||
.onFailure { Logger.w(it) { "Failed to close cached database ${anonymizeDbName(dbName)}" } }
|
||||
Logger.d { "Closed inactive database ${anonymizeDbName(dbName)} to free connections" }
|
||||
}
|
||||
|
||||
private val limitedIo = dispatchers.io.limitedParallelism(4)
|
||||
|
||||
/** Execute [block] with the current DB instance. */
|
||||
|
|
@ -184,9 +209,8 @@ open class DatabaseManager(
|
|||
val limit = getCurrentCacheLimit()
|
||||
val all = listExistingDbNames()
|
||||
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
|
||||
val deviceDbs = all.filterNot {
|
||||
it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME
|
||||
}
|
||||
val deviceDbs =
|
||||
all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME }
|
||||
|
||||
if (deviceDbs.size <= limit) return@withLock
|
||||
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
|
||||
|
|
@ -194,12 +218,12 @@ open class DatabaseManager(
|
|||
|
||||
victims.forEach { name ->
|
||||
runCatching {
|
||||
dbCache.remove(name)?.close()
|
||||
closeCachedDatabase(name)
|
||||
deleteDatabase(name)
|
||||
datastore.edit { it.remove(lastUsedKey(name)) }
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to evict database $name" } }
|
||||
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
|
||||
.onSuccess { Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" } }
|
||||
.onFailure { Logger.w(it) { "Failed to evict database ${anonymizeDbName(name)}" } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,11 +243,11 @@ open class DatabaseManager(
|
|||
|
||||
if (fs.exists(legacyPath)) {
|
||||
runCatching {
|
||||
dbCache.remove(legacy)?.close()
|
||||
closeCachedDatabase(legacy)
|
||||
deleteDatabase(legacy)
|
||||
}
|
||||
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
|
||||
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
|
||||
.onSuccess { Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" } }
|
||||
.onFailure { Logger.w(it) { "Failed to delete legacy database ${anonymizeDbName(legacy)}" } }
|
||||
}
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
|
|
@ -31,8 +33,10 @@ import java.io.File
|
|||
/**
|
||||
* Resolves the desktop data directory for persistent storage (DataStore files, Room database). Defaults to
|
||||
* `~/.meshtastic/`. Override via `MESHTASTIC_DATA_DIR` environment variable.
|
||||
*
|
||||
* Shared between `core:database` and `desktop` module to ensure all persistent data is co-located.
|
||||
*/
|
||||
private fun desktopDataDir(): String {
|
||||
fun desktopDataDir(): String {
|
||||
val override = System.getenv("MESHTASTIC_DATA_DIR")
|
||||
if (!override.isNullOrBlank()) return override
|
||||
return System.getProperty("user.home") + "/.meshtastic"
|
||||
|
|
@ -74,5 +78,8 @@ actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
|||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
File(dir).mkdirs()
|
||||
return PreferenceDataStoreFactory.create(produceFile = { File(dir, "$name.preferences_pb") })
|
||||
return PreferenceDataStoreFactory.create(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
|
||||
produceFile = { File(dir, "$name.preferences_pb") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue