From ef8c5878ffc2abdfdfbafd6ff6291ab5ae58ce36 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 20 Mar 2026 15:25:50 -0500 Subject: [PATCH] chore: Upgrade to Room 3.0 and refactor related components --- .../index.md | 9 + .../metadata.json | 8 + .../plan.md | 25 ++ .../spec.md | 24 ++ .../index.md | 9 + .../metadata.json | 8 + .../plan.md | 20 ++ .../spec.md | 20 ++ .../core/database/DatabaseBuilder.kt | 59 +++++ .../core/database/DatabaseBuilder.kt | 38 +++ .../core/database/DatabaseManager.kt | 233 ++++++++++++++++++ .../core/database/DatabaseBuilder.kt | 76 ++++++ .../core/database/DatabaseBuilder.kt | 71 ++++++ 13 files changed, 600 insertions(+) create mode 100644 conductor/tracks/extract_database_manager_kmp_20260320/index.md create mode 100644 conductor/tracks/extract_database_manager_kmp_20260320/metadata.json create mode 100644 conductor/tracks/extract_database_manager_kmp_20260320/plan.md create mode 100644 conductor/tracks/extract_database_manager_kmp_20260320/spec.md create mode 100644 conductor/tracks/extract_radio_interface_kmp_20260320/index.md create mode 100644 conductor/tracks/extract_radio_interface_kmp_20260320/metadata.json create mode 100644 conductor/tracks/extract_radio_interface_kmp_20260320/plan.md create mode 100644 conductor/tracks/extract_radio_interface_kmp_20260320/spec.md create mode 100644 core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt create mode 100644 core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt create mode 100644 core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt diff --git a/conductor/tracks/extract_database_manager_kmp_20260320/index.md b/conductor/tracks/extract_database_manager_kmp_20260320/index.md new file mode 100644 index 000000000..c5da8cfd3 --- /dev/null +++ b/conductor/tracks/extract_database_manager_kmp_20260320/index.md @@ -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`. diff --git a/conductor/tracks/extract_database_manager_kmp_20260320/metadata.json b/conductor/tracks/extract_database_manager_kmp_20260320/metadata.json new file mode 100644 index 000000000..7dff1187a --- /dev/null +++ b/conductor/tracks/extract_database_manager_kmp_20260320/metadata.json @@ -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" +} diff --git a/conductor/tracks/extract_database_manager_kmp_20260320/plan.md b/conductor/tracks/extract_database_manager_kmp_20260320/plan.md new file mode 100644 index 000000000..c1db4e8b2 --- /dev/null +++ b/conductor/tracks/extract_database_manager_kmp_20260320/plan.md @@ -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` 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. diff --git a/conductor/tracks/extract_database_manager_kmp_20260320/spec.md b/conductor/tracks/extract_database_manager_kmp_20260320/spec.md new file mode 100644 index 000000000..d0f522753 --- /dev/null +++ b/conductor/tracks/extract_database_manager_kmp_20260320/spec.md @@ -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. diff --git a/conductor/tracks/extract_radio_interface_kmp_20260320/index.md b/conductor/tracks/extract_radio_interface_kmp_20260320/index.md new file mode 100644 index 000000000..47aea8762 --- /dev/null +++ b/conductor/tracks/extract_radio_interface_kmp_20260320/index.md @@ -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`. diff --git a/conductor/tracks/extract_radio_interface_kmp_20260320/metadata.json b/conductor/tracks/extract_radio_interface_kmp_20260320/metadata.json new file mode 100644 index 000000000..b424ea588 --- /dev/null +++ b/conductor/tracks/extract_radio_interface_kmp_20260320/metadata.json @@ -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" +} diff --git a/conductor/tracks/extract_radio_interface_kmp_20260320/plan.md b/conductor/tracks/extract_radio_interface_kmp_20260320/plan.md new file mode 100644 index 000000000..7a7c632f9 --- /dev/null +++ b/conductor/tracks/extract_radio_interface_kmp_20260320/plan.md @@ -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. diff --git a/conductor/tracks/extract_radio_interface_kmp_20260320/spec.md b/conductor/tracks/extract_radio_interface_kmp_20260320/spec.md new file mode 100644 index 000000000..15605ece6 --- /dev/null +++ b/conductor/tracks/extract_radio_interface_kmp_20260320/spec.md @@ -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. diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt new file mode 100644 index 000000000..4c7b740f6 --- /dev/null +++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -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 . + */ +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 { + val app = ContextServices.app + val dbFile = app.getDatabasePath(dbName) + return Room.databaseBuilder( + 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 = PreferenceDataStoreFactory.create( + produceFile = { ContextServices.app.preferencesDataStoreFile(name) } +) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt new file mode 100644 index 000000000..32bed287c --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -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 . + */ +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 + +/** 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 diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt new file mode 100644 index 000000000..0e7f2cd25 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -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 . + */ +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, + 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 = 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(null) + override val currentDb: StateFlow = + _currentDb.filterNotNull().stateIn( + managerScope, + SharingStarted.Eagerly, + getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build() + ) + + private val _currentAddress = MutableStateFlow(null) + val currentAddress: StateFlow = _currentAddress + + private val dbCache = mutableMapOf() // 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 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 { + 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 + } +} diff --git a/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt new file mode 100644 index 000000000..48a02a7f2 --- /dev/null +++ b/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -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 . + */ +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 { + val dbFilePath = documentDirectory() + "/$dbName.db" + return Room.databaseBuilder( + 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 { + 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) +} diff --git a/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt new file mode 100644 index 000000000..4c6bffa33 --- /dev/null +++ b/core/database/src/jvmMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -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 . + */ +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 { + val dbFile = File(desktopDataDir(), "$dbName.db") + dbFile.parentFile?.mkdirs() + return Room.databaseBuilder( + 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 { + val dir = desktopDataDir() + "/datastore" + File(dir).mkdirs() + return PreferenceDataStoreFactory.create( + produceFile = { File(dir, "$name.preferences_pb") } + ) +}