chore: Upgrade to Room 3.0 and refactor related components

This commit is contained in:
James Rich 2026-03-20 15:25:50 -05:00
parent 785b698542
commit ef8c5878ff
13 changed files with 600 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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