mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
chore: Upgrade to Room 3.0 and refactor related components
This commit is contained in:
parent
785b698542
commit
ef8c5878ff
13 changed files with 600 additions and 0 deletions
|
|
@ -0,0 +1,9 @@
|
|||
# Track: Extract DatabaseManager to KMP
|
||||
|
||||
## Documents
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
|
||||
## Context
|
||||
Meshtastic-Android is designed to support per-node databases. Currently, the logic for managing these databases is in `androidMain`, and the desktop module stubs this out, which leads to a lack of feature parity. This track aims to extract that logic into `commonMain`.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "extract_database_manager_kmp_20260320",
|
||||
"name": "Extract DatabaseManager to KMP",
|
||||
"description": "Move core database management logic (per-node databases, LRU) to commonMain for target parity.",
|
||||
"status": "completed",
|
||||
"tags": ["core", "database", "kmp", "desktop"],
|
||||
"created_at": "2026-03-20T12:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Implementation Plan - Extract DatabaseManager to KMP
|
||||
|
||||
## Phase 1: Multiplatform Database Abstraction
|
||||
- [x] Define `expect fun buildRoomDb(dbName: String): MeshtasticDatabase` in `commonMain`.
|
||||
- [x] Implement `actual fun buildRoomDb` for Android (using `Application.getDatabasePath`).
|
||||
- [x] Implement `actual fun buildRoomDb` for JVM/Desktop (using the established `~/.meshtastic` data directory).
|
||||
- [x] Implement `actual fun buildRoomDb` for iOS (using `NSDocumentDirectory`).
|
||||
- [x] Update `DatabaseConstants` with shared keys and default values.
|
||||
|
||||
## Phase 2: KMP DataStore & File I/O
|
||||
- [x] Replace Android `SharedPreferences` in `DatabaseManager` with a KMP-ready `DataStore<Preferences>` instance named `DatabasePrefs`.
|
||||
- [x] Introduce an `expect fun deleteDatabase(dbName: String)` or similar Okio-based deletion helper.
|
||||
- [x] Refactor database file listing to use `okio.FileSystem.SYSTEM` instead of `java.io.File`.
|
||||
|
||||
## Phase 3: Logic Extraction
|
||||
- [x] Move `DatabaseManager.kt` from `core:database/androidMain` to `core:database/commonMain`.
|
||||
- [x] Refactor `DatabaseManager` to use the new `buildRoomDb`, `DataStore`, and `FileSystem` abstractions.
|
||||
- [x] Ensure `DatabaseManager` is annotated with Koin `@Single` and correctly binds to `DatabaseProvider` and `SharedDatabaseManager` (from `core:common`).
|
||||
- [x] Remove `DesktopDatabaseManager` from `desktop` module.
|
||||
- [x] Update the DI (Koin) graph in `app` and `desktop` to wire the new shared `DatabaseManager`.
|
||||
|
||||
## Phase 4: Verification
|
||||
- [x] Add unit tests in `core:database/commonTest` to verify that `switchActiveDatabase` correctly swaps databases and that the LRU eviction limit is respected.
|
||||
- [x] Perform manual verification on Desktop to ensure that connecting to different nodes creates separate `.db` files in `~/.meshtastic/`.
|
||||
- [x] Verify that the `core:database` module still compiles for Android and iOS targets.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Specification - Extract DatabaseManager to KMP
|
||||
|
||||
## Overview
|
||||
Meshtastic-Android is designed to support per-node databases (e.g., `db_!1234abcd.db`). Currently, the logic for managing these databases (switching, LRU caching, eviction) is trapped in `core:database/androidMain`. The Desktop implementation stubs this out, forcing all nodes to share a single database, which is a major architectural regression and leads to data pollution across different devices.
|
||||
|
||||
This track will move the core `DatabaseManager` logic to `commonMain`, enabling full feature parity for database management on Android, Desktop, and iOS.
|
||||
|
||||
## Functional Requirements
|
||||
- **Per-Node Databases**: Desktop and iOS must support creating and switching between separate databases based on the connected device's address.
|
||||
- **LRU Eviction**: Implement an LRU (Least Recently Used) cache for database instances on all platforms.
|
||||
- **Cache Limits**: The database cache limit must be configurable and respected across all platforms.
|
||||
- **Legacy Cleanup**: Maintain logic for cleaning up legacy databases where applicable.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **KMP Purity**: Use only Kotlin Multiplatform-ready libraries (`kotlinx-coroutines`, `okio`, `androidx-datastore`).
|
||||
- **Dependency Injection**: Use Koin to wire the shared `DatabaseManager` into all app targets.
|
||||
- **Platform Specifics**: Isolate platform-specific path resolution (e.g., Android `getDatabasePath` vs. JVM `user.home`) using the `expect`/`actual` pattern.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. `DatabaseManager` resides in `core:database/commonMain`.
|
||||
2. `DesktopDatabaseManager` (the stub) is deleted.
|
||||
3. Desktop creates unique database files when connecting to different nodes.
|
||||
4. Unit tests in `commonTest` verify the LRU eviction logic using an Okio in-memory filesystem (or temporary test directory).
|
||||
5. No `android.*` or `java.*` imports remain in the shared database management logic.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Track: Extract RadioInterfaceService to KMP
|
||||
|
||||
## Documents
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
|
||||
## Context
|
||||
Meshtastic-Android and Desktop orchestrate their hardware connections (TCP, Serial, BLE) independently using `AndroidRadioInterfaceService` and `DesktopRadioInterfaceService`. This duplicates complex logic like reconnect loops and state emission. This track aims to unify that logic into `commonMain`.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "extract_radio_interface_kmp_20260320",
|
||||
"name": "Extract RadioInterfaceService to KMP",
|
||||
"description": "Unify the connection orchestration lifecycle (TCP, Serial, BLE) into a shared multiplatform service.",
|
||||
"status": "in_progress",
|
||||
"tags": ["core", "service", "kmp", "desktop", "radio", "connection"],
|
||||
"created_at": "2026-03-20T12:00:00Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Implementation Plan - Extract RadioInterfaceService to KMP
|
||||
|
||||
## Phase 1: Research & Abstraction
|
||||
- [ ] Review `AndroidRadioInterfaceService` and `DesktopRadioInterfaceService` to identify identical connection loop logic.
|
||||
- [ ] Identify platform-specific dependencies in both implementations (e.g., Android `BluetoothDevice`, notifications).
|
||||
- [ ] Define shared abstractions (e.g., `TransportFactory`, `NotificationDelegate`) if needed to decouple platform-specific side effects.
|
||||
|
||||
## Phase 2: Logic Extraction
|
||||
- [ ] Create `SharedRadioInterfaceService` in `core:service/commonMain`.
|
||||
- [ ] Move the core connection loop, state management, and retry logic into the shared service.
|
||||
- [ ] Adapt Android and Desktop to use the new shared service.
|
||||
|
||||
## Phase 3: Cleanup & Wiring
|
||||
- [ ] Remove `DesktopRadioInterfaceService`.
|
||||
- [ ] Refactor or remove `AndroidRadioInterfaceService` if entirely superseded.
|
||||
- [ ] Update Koin DI graph in `core:service/commonMain` to provide the unified service.
|
||||
|
||||
## Phase 4: Verification
|
||||
- [ ] Verify that `core:service` and `:app` compile cleanly for Android and Desktop.
|
||||
- [ ] Write or update unit tests in `commonTest` to cover the shared connection lifecycle logic.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Specification - Extract RadioInterfaceService to KMP
|
||||
|
||||
## Overview
|
||||
Currently, the connection orchestration logic for establishing, monitoring, and tearing down connections with Meshtastic radios is duplicated. Android uses `AndroidRadioInterfaceService` in `core:service/androidMain`, and Desktop uses `DesktopRadioInterfaceService` in the `desktop` module. This duplicates core state management (connecting, connected, disconnecting) and the interactions with the shared `TcpTransport`, `SerialTransport`, and `BleTransport`.
|
||||
|
||||
This track aims to abstract the remaining platform-specific connection logic (if any) and move the bulk of `RadioInterfaceService` into `core:repository/commonMain` or `core:service/commonMain`, unifying the connection lifecycle across all targets.
|
||||
|
||||
## Functional Requirements
|
||||
- **Unified Connection Lifecycle**: A single `RadioInterfaceService` implementation in `commonMain` should handle connection state management (connecting, active, disconnect, reconnect loops).
|
||||
- **Transport Abstraction**: The service must interact with connections via a multiplatform interface, presumably standardizing around `RadioTransport` or `ConnectionFactory`.
|
||||
- **Platform Parity**: Desktop and Android must use the exact same logic for detecting disconnects and issuing reconnects.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **KMP Purity**: The unified service must not depend on `android.*` or `java.*` specific APIs for its core lifecycle management.
|
||||
- **Dependency Injection**: Utilize Koin in `commonMain` to provide the unified service.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. `DesktopRadioInterfaceService` is removed.
|
||||
2. `AndroidRadioInterfaceService` is replaced by a shared implementation in `commonMain` (e.g., `SharedRadioInterfaceService`).
|
||||
3. Both Android and Desktop can successfully connect, disconnect, and handle unexpected drops using the shared logic.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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
|
||||
* 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 org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for Android with the given [dbName]. */
|
||||
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
|
||||
val app = ContextServices.app
|
||||
val dbFile = app.getDatabasePath(dbName)
|
||||
return Room.databaseBuilder<MeshtasticDatabase>(
|
||||
context = app.applicationContext,
|
||||
name = dbFile.absolutePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() }
|
||||
).configureCommon()
|
||||
}
|
||||
|
||||
/** Returns the Android directory where database files are stored. */
|
||||
actual fun getDatabaseDirectory(): Path {
|
||||
val app = ContextServices.app
|
||||
return app.getDatabasePath("").parentFile!!.absolutePath.toPath()
|
||||
}
|
||||
|
||||
/** Deletes the Android database using the platform-specific deleteDatabase helper. */
|
||||
actual fun deleteDatabase(dbName: String) {
|
||||
ContextServices.app.deleteDatabase(dbName)
|
||||
}
|
||||
|
||||
/** Returns the system FileSystem for Android. */
|
||||
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) }
|
||||
)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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
|
||||
* 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 org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room3.RoomDatabase
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for the current platform with the given [dbName]. */
|
||||
expect fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase>
|
||||
|
||||
/** Returns the platform-specific directory where database files are stored. */
|
||||
expect fun getDatabaseDirectory(): Path
|
||||
|
||||
/** Deletes the database with the given [dbName] and its associated files (e.g., -wal, -shm). */
|
||||
expect fun deleteDatabase(dbName: String)
|
||||
|
||||
/** Returns the [FileSystem] to use for database file operations. */
|
||||
expect fun getFileSystem(): FileSystem
|
||||
|
||||
/** Creates a platform-specific [DataStore] for database-related preferences. */
|
||||
expect fun createDatabaseDataStore(name: String): DataStore<Preferences>
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
/*
|
||||
* 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
|
||||
* 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 org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
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
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager
|
||||
|
||||
/** Manages per-device Room database instances for node data, with LRU eviction. */
|
||||
@Single(binds = [DatabaseProvider::class, SharedDatabaseManager::class])
|
||||
@Suppress("TooManyFunctions")
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
open class DatabaseManager(
|
||||
@Named("DatabaseDataStore") private val datastore: DataStore<Preferences>,
|
||||
private val dispatchers: CoroutineDispatchers
|
||||
) :
|
||||
DatabaseProvider,
|
||||
SharedDatabaseManager {
|
||||
|
||||
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val cacheLimitKey = intPreferencesKey(DatabaseConstants.CACHE_LIMIT_KEY)
|
||||
private val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY)
|
||||
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 }
|
||||
.stateIn(managerScope, SharingStarted.Eagerly, DatabaseConstants.DEFAULT_CACHE_LIMIT)
|
||||
|
||||
override fun getCurrentCacheLimit(): Int = cacheLimit.value
|
||||
|
||||
override fun setCacheLimit(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
managerScope.launch {
|
||||
datastore.edit { it[cacheLimitKey] = clamped }
|
||||
// Enforce asynchronously with current active DB protected
|
||||
val active = _currentDb.value?.let { buildDbName(_currentAddress.value) }
|
||||
?: DatabaseConstants.DEFAULT_DB_NAME
|
||||
enforceCacheLimit(activeDbName = active)
|
||||
}
|
||||
}
|
||||
|
||||
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
|
||||
override val currentDb: StateFlow<MeshtasticDatabase> =
|
||||
_currentDb.filterNotNull().stateIn(
|
||||
managerScope,
|
||||
SharingStarted.Eagerly,
|
||||
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build()
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Switch active database to the one associated with [address]. Serialized via mutex. */
|
||||
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
|
||||
val dbName = buildDbName(address)
|
||||
|
||||
// Remember the previously active DB name (any) so we can record its last-used time as well.
|
||||
val previousDbName = _currentDb.value?.let { buildDbName(_currentAddress.value) }
|
||||
|
||||
// Fast path: no-op if already on this address
|
||||
if (_currentAddress.value == address && _currentDb.value != null) {
|
||||
markLastUsed(dbName)
|
||||
return@withLock
|
||||
}
|
||||
|
||||
// Build/open Room DB off the main thread
|
||||
val db =
|
||||
dbCache[dbName]
|
||||
?: withContext(dispatchers.io) { getDatabaseBuilder(dbName).build() }.also { dbCache[dbName] = it }
|
||||
|
||||
_currentDb.value = db
|
||||
_currentAddress.value = address
|
||||
markLastUsed(dbName)
|
||||
// Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp
|
||||
previousDbName?.let { markLastUsed(it) }
|
||||
|
||||
// Defer LRU eviction so switch is not blocked by filesystem work
|
||||
managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = dbName) }
|
||||
|
||||
// One-time cleanup: remove legacy DB if present and not active
|
||||
managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }
|
||||
|
||||
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. */
|
||||
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
|
||||
val db = _currentDb.value ?: return@withContext null
|
||||
val active = buildDbName(_currentAddress.value)
|
||||
markLastUsed(active)
|
||||
block(db)
|
||||
}
|
||||
|
||||
/** Returns true if a database exists for the given device address. */
|
||||
override fun hasDatabaseFor(address: String?): Boolean {
|
||||
if (address.isNullOrBlank() || address == "n") return false
|
||||
val dbName = buildDbName(address)
|
||||
val path = getDatabaseDirectory().resolve("$dbName.db")
|
||||
return getFileSystem().exists(path)
|
||||
}
|
||||
|
||||
private fun markLastUsed(dbName: String) {
|
||||
managerScope.launch {
|
||||
datastore.edit { it[lastUsedKey(dbName)] = nowMillis }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun lastUsed(dbName: String): Long {
|
||||
val key = lastUsedKey(dbName)
|
||||
val v = datastore.data.first()[key] ?: 0L
|
||||
return if (v == 0L) {
|
||||
val path = getDatabaseDirectory().resolve("$dbName.db")
|
||||
getFileSystem().metadataOrNull(path)?.lastModifiedAtMillis ?: 0L
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
private fun listExistingDbNames(): List<String> {
|
||||
val dir = getDatabaseDirectory()
|
||||
val fs = getFileSystem()
|
||||
if (!fs.exists(dir)) return emptyList()
|
||||
|
||||
return fs.list(dir).map { it.name }
|
||||
.filter { it.startsWith(DatabaseConstants.DB_PREFIX) }
|
||||
.filter { it.endsWith(".db") }
|
||||
.map { it.removeSuffix(".db") }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock {
|
||||
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 }
|
||||
|
||||
if (deviceDbs.size <= limit) return@withLock
|
||||
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
|
||||
val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot)
|
||||
|
||||
victims.forEach { name ->
|
||||
runCatching {
|
||||
dbCache.remove(name)?.close()
|
||||
deleteDatabase(name)
|
||||
datastore.edit { it.remove(lastUsedKey(name)) }
|
||||
}.onFailure { Logger.w(it) { "Failed to evict database $name" } }
|
||||
Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock {
|
||||
val cleaned = datastore.data.first()[legacyCleanedKey] ?: false
|
||||
if (cleaned) return@withLock
|
||||
|
||||
val legacy = DatabaseConstants.LEGACY_DB_NAME
|
||||
if (legacy == activeDbName) {
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val dir = getDatabaseDirectory()
|
||||
val fs = getFileSystem()
|
||||
val legacyPath = dir.resolve("$legacy.db")
|
||||
|
||||
if (fs.exists(legacyPath)) {
|
||||
runCatching {
|
||||
dbCache.remove(legacy)?.close()
|
||||
deleteDatabase(legacy)
|
||||
}.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
|
||||
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
|
||||
}
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
}
|
||||
|
||||
/** Closes all open databases and cancels background work. */
|
||||
fun close() {
|
||||
managerScope.cancel()
|
||||
dbCache.values.forEach { it.close() }
|
||||
dbCache.clear()
|
||||
_currentDb.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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
|
||||
* 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 org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for iOS with the given [dbName]. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
|
||||
val dbFilePath = documentDirectory() + "/$dbName.db"
|
||||
return Room.databaseBuilder<MeshtasticDatabase>(
|
||||
name = dbFilePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() }
|
||||
).configureCommon()
|
||||
}
|
||||
|
||||
/** Returns the iOS directory where database files are stored. */
|
||||
actual fun getDatabaseDirectory(): Path = documentDirectory().toPath()
|
||||
|
||||
/** Deletes the database and its Room-associated files on iOS. */
|
||||
actual fun deleteDatabase(dbName: String) {
|
||||
val dir = documentDirectory()
|
||||
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db", null)
|
||||
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db-wal", null)
|
||||
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db-shm", null)
|
||||
}
|
||||
|
||||
/** Returns the system FileSystem for iOS. */
|
||||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates an iOS DataStore for database preferences. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = documentDirectory() + "/datastore"
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
|
||||
return PreferenceDataStoreFactory.create(
|
||||
produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun documentDirectory(): String {
|
||||
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
|
||||
directory = NSDocumentDirectory,
|
||||
inDomain = NSUserDomainMask,
|
||||
appropriateForURL = null,
|
||||
create = false,
|
||||
error = null,
|
||||
)
|
||||
return requireNotNull(documentDirectory?.path)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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
|
||||
* 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 org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
|
||||
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.
|
||||
*/
|
||||
private fun desktopDataDir(): String {
|
||||
val override = System.getenv("MESHTASTIC_DATA_DIR")
|
||||
if (!override.isNullOrBlank()) return override
|
||||
return System.getProperty("user.home") + "/.meshtastic"
|
||||
}
|
||||
|
||||
/** Returns a [RoomDatabase.Builder] configured for JVM/Desktop with the given [dbName]. */
|
||||
actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase> {
|
||||
val dbFile = File(desktopDataDir(), "$dbName.db")
|
||||
dbFile.parentFile?.mkdirs()
|
||||
return Room.databaseBuilder<MeshtasticDatabase>(
|
||||
name = dbFile.absolutePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() }
|
||||
).configureCommon()
|
||||
}
|
||||
|
||||
/** Returns the JVM/Desktop directory where database files are stored. */
|
||||
actual fun getDatabaseDirectory(): Path = desktopDataDir().toPath()
|
||||
|
||||
/** Deletes the database and its Room-associated files on JVM. */
|
||||
actual fun deleteDatabase(dbName: String) {
|
||||
val dir = desktopDataDir()
|
||||
File(dir, "$dbName.db").delete()
|
||||
File(dir, "$dbName.db-wal").delete()
|
||||
File(dir, "$dbName.db-shm").delete()
|
||||
}
|
||||
|
||||
/** Returns the system FileSystem for JVM. */
|
||||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
/** Creates a JVM DataStore for database preferences in the data directory. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = desktopDataDir() + "/datastore"
|
||||
File(dir).mkdirs()
|
||||
return PreferenceDataStoreFactory.create(
|
||||
produceFile = { File(dir, "$name.preferences_pb") }
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue