mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Migrate to Room 3.0 and update related documentation and tracks (#4865)
This commit is contained in:
parent
6cdd10d936
commit
c4087c2ab7
63 changed files with 1097 additions and 921 deletions
|
|
@ -35,6 +35,8 @@ kotlin {
|
|||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.okio)
|
||||
|
||||
api(projects.core.common)
|
||||
implementation(projects.core.di)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
|
|||
|
|
@ -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("dummy.db").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) })
|
||||
|
|
@ -16,9 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.core.database")
|
||||
class CoreDatabaseAndroidModule
|
||||
@Module class CoreDatabaseAndroidModule
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room3.TypeConverter
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.ByteString
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -16,10 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.room.Room
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
|
@ -29,49 +31,65 @@ 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.database.MeshtasticDatabase.Companion.configureCommon
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import java.io.File
|
||||
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(private val app: Application, private val dispatchers: CoroutineDispatchers) :
|
||||
DatabaseProvider,
|
||||
open class DatabaseManager(
|
||||
@Named("DatabaseDataStore") private val datastore: DataStore<Preferences>,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : DatabaseProvider,
|
||||
SharedDatabaseManager {
|
||||
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
|
||||
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
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.
|
||||
private val _cacheLimit = MutableStateFlow(getCurrentCacheLimit())
|
||||
override val cacheLimit: StateFlow<Int> = _cacheLimit
|
||||
override val cacheLimit: StateFlow<Int> =
|
||||
datastore.data
|
||||
.map { it[cacheLimitKey] ?: DatabaseConstants.DEFAULT_CACHE_LIMIT }
|
||||
.stateIn(managerScope, SharingStarted.Eagerly, DatabaseConstants.DEFAULT_CACHE_LIMIT)
|
||||
|
||||
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
|
||||
private val prefsListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == DatabaseConstants.CACHE_LIMIT_KEY) {
|
||||
_cacheLimit.value = getCurrentCacheLimit()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
init {
|
||||
prefs.registerOnSharedPreferenceChangeListener(prefsListener)
|
||||
}
|
||||
|
||||
private val _currentDb = MutableStateFlow<MeshtasticDatabase?>(null)
|
||||
override val currentDb: StateFlow<MeshtasticDatabase> =
|
||||
_currentDb.filterNotNull().stateIn(managerScope, SharingStarted.Eagerly, buildRoomDb(app, defaultDbName()))
|
||||
_currentDb
|
||||
.filterNotNull()
|
||||
.stateIn(
|
||||
managerScope,
|
||||
SharingStarted.Eagerly,
|
||||
getDatabaseBuilder(DatabaseConstants.DEFAULT_DB_NAME).build(),
|
||||
)
|
||||
|
||||
private val _currentAddress = MutableStateFlow<String?>(null)
|
||||
val currentAddress: StateFlow<String?> = _currentAddress
|
||||
|
|
@ -99,13 +117,12 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
// Build/open Room DB off the main thread
|
||||
val db =
|
||||
dbCache[dbName]
|
||||
?: withContext(dispatchers.io) { buildRoomDb(app, dbName) }.also { dbCache[dbName] = it }
|
||||
?: 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
|
||||
// even on first run after upgrade where no timestamp might exist yet.
|
||||
previousDbName?.let { markLastUsed(it) }
|
||||
|
||||
// Defer LRU eviction so switch is not blocked by filesystem work
|
||||
|
|
@ -131,26 +148,35 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
override fun hasDatabaseFor(address: String?): Boolean {
|
||||
if (address.isNullOrBlank() || address == "n") return false
|
||||
val dbName = buildDbName(address)
|
||||
return getDbFile(app, dbName) != null
|
||||
val path = getDatabaseDirectory().resolve("$dbName.db")
|
||||
return getFileSystem().exists(path)
|
||||
}
|
||||
|
||||
private fun markLastUsed(dbName: String) {
|
||||
prefs.edit().putLong(lastUsedKey(dbName), nowMillis).apply()
|
||||
managerScope.launch { datastore.edit { it[lastUsedKey(dbName)] = nowMillis } }
|
||||
}
|
||||
|
||||
private fun lastUsed(dbName: String): Long {
|
||||
val k = lastUsedKey(dbName)
|
||||
val v = prefs.getLong(k, 0L)
|
||||
return if (v == 0L) getDbFile(app, dbName)?.lastModified() ?: 0L else v
|
||||
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 base = app.getDatabasePath(DatabaseConstants.LEGACY_DB_NAME)
|
||||
val dir = base.parentFile ?: return emptyList()
|
||||
val names = dir.listFiles()?.mapNotNull { f -> f.name } ?: emptyList()
|
||||
return names
|
||||
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) }
|
||||
.filterNot { it.endsWith("-wal") || it.endsWith("-shm") }
|
||||
.filter { it.endsWith(".db") }
|
||||
.map { it.removeSuffix(".db") }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
|
|
@ -160,65 +186,45 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
// 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 }
|
||||
Logger.d {
|
||||
"LRU check: limit=$limit, active=${anonymizeDbName(
|
||||
activeDbName,
|
||||
)}, deviceDbs=${deviceDbs.joinToString(", ") {
|
||||
anonymizeDbName(it)
|
||||
}}"
|
||||
}
|
||||
|
||||
if (deviceDbs.size <= limit) return@withLock
|
||||
val usageSnapshot = deviceDbs.associateWith { lastUsed(it) }
|
||||
Logger.d {
|
||||
"LRU lastUsed(ms): ${usageSnapshot.entries.joinToString(", ") { (name, ts) ->
|
||||
"${anonymizeDbName(name)}=$ts"
|
||||
}}"
|
||||
}
|
||||
val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot)
|
||||
Logger.i { "LRU victims: ${victims.joinToString(", ") { anonymizeDbName(it) }}" }
|
||||
|
||||
victims.forEach { name ->
|
||||
runCatching { dbCache.remove(name)?.close() }
|
||||
.onFailure { Logger.w(it) { "Failed to close database $name" } }
|
||||
app.deleteDatabase(name)
|
||||
prefs.edit().remove(lastUsedKey(name)).apply()
|
||||
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)}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentCacheLimit(): Int = prefs
|
||||
.getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT)
|
||||
.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
|
||||
override fun setCacheLimit(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
if (clamped == getCurrentCacheLimit()) return
|
||||
prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply()
|
||||
_cacheLimit.value = clamped
|
||||
// Enforce asynchronously with current active DB protected
|
||||
val active = _currentDb.value?.let { buildDbName(_currentAddress.value) } ?: defaultDbName()
|
||||
managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = active) }
|
||||
}
|
||||
|
||||
private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock {
|
||||
if (prefs.getBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, false)) return@withLock
|
||||
val cleaned = datastore.data.first()[legacyCleanedKey] ?: false
|
||||
if (cleaned) return@withLock
|
||||
|
||||
val legacy = DatabaseConstants.LEGACY_DB_NAME
|
||||
if (legacy == activeDbName) {
|
||||
// Never delete the active DB; mark as cleaned to avoid repeated checks
|
||||
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
return@withLock
|
||||
}
|
||||
val legacyFile = getDbFile(app, legacy)
|
||||
if (legacyFile != null) {
|
||||
runCatching { dbCache.remove(legacy)?.close() }
|
||||
.onFailure { Logger.w(it) { "Failed to close legacy database $legacy before deletion" } }
|
||||
val deleted = app.deleteDatabase(legacy)
|
||||
if (deleted) {
|
||||
Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" }
|
||||
} else {
|
||||
Logger.w { "Attempted to delete legacy DB $legacy but deleteDatabase returned false" }
|
||||
|
||||
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)}" }
|
||||
}
|
||||
prefs.edit().putBoolean(DatabaseConstants.LEGACY_DB_CLEANED_KEY, true).apply()
|
||||
datastore.edit { it[legacyCleanedKey] = true }
|
||||
}
|
||||
|
||||
/** Closes all open databases and cancels background work. */
|
||||
|
|
@ -229,19 +235,3 @@ open class DatabaseManager(private val app: Application, private val dispatchers
|
|||
_currentDb.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// File-private helpers
|
||||
private fun defaultDbName(): String = DatabaseConstants.DEFAULT_DB_NAME
|
||||
|
||||
private fun lastUsedKey(dbName: String) = "db_last_used:$dbName"
|
||||
|
||||
private fun buildRoomDb(app: Application, dbName: String): MeshtasticDatabase =
|
||||
Room.databaseBuilder<MeshtasticDatabase>(
|
||||
context = app.applicationContext,
|
||||
name = app.getDatabasePath(dbName).absolutePath,
|
||||
factory = { MeshtasticDatabaseConstructor.initialize() },
|
||||
)
|
||||
.configureCommon()
|
||||
.build()
|
||||
|
||||
private fun getDbFile(app: Application, dbName: String): File? = app.getDatabasePath(dbName).takeIf { it.exists() }
|
||||
|
|
@ -16,13 +16,13 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.DeleteTable
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room3.AutoMigration
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.DeleteColumn
|
||||
import androidx.room3.DeleteTable
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.room3.TypeConverters
|
||||
import androidx.room3.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.meshtastic.core.database.dao.DeviceHardwareDao
|
||||
|
|
@ -99,8 +99,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
|||
version = 37,
|
||||
exportSchema = true,
|
||||
)
|
||||
@androidx.room.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
@TypeConverters(Converters::class)
|
||||
@androidx.room3.DaoReturnTypeConverters(androidx.room3.paging.PagingSourceDaoReturnTypeConverter::class)
|
||||
abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
abstract fun nodeInfoDao(): NodeInfoDao
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.room.RoomDatabaseConstructor
|
||||
import androidx.room3.RoomDatabaseConstructor
|
||||
|
||||
@Suppress("NO_ACTUAL_FOR_EXPECT", "KotlinNoActualForExpect")
|
||||
expect object MeshtasticDatabaseConstructor : RoomDatabaseConstructor<MeshtasticDatabase> {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import org.meshtastic.core.database.entity.DeviceHardwareEntity
|
||||
|
||||
@Dao
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.MapColumn
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.MapColumn
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Update
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,14 @@ package org.meshtastic.core.database.di
|
|||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.createDatabaseDataStore
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.core.database")
|
||||
class CoreDatabaseModule
|
||||
class CoreDatabaseModule {
|
||||
@Single
|
||||
@Named("DatabaseDataStore")
|
||||
fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.proto.FromRadio
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
|
||||
@Entity(tableName = "my_node")
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import androidx.room3.Relation
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Relation
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import androidx.room3.Relation
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
|
||||
@Entity(tableName = "quick_chat")
|
||||
data class QuickChatAction(
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import org.meshtastic.proto.Position
|
||||
|
||||
@Entity(
|
||||
|
|
|
|||
|
|
@ -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,70 @@
|
|||
/*
|
||||
* 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") })
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
/*
|
||||
* 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.network.radio
|
||||
|
||||
import android.app.Application
|
||||
import android.provider.Settings
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.util.BinaryLogFile
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toRemoteExceptions
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
/**
|
||||
* Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
|
||||
* etc...
|
||||
*
|
||||
* This service is not exposed outside of this process.
|
||||
*
|
||||
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
|
||||
* can be stubbed out with a simulated version as needed.
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class AndroidRadioInterfaceService(
|
||||
private val context: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val analytics: PlatformAnalytics,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
|
||||
listOf(
|
||||
org.meshtastic.core.model.DeviceType.BLE,
|
||||
org.meshtastic.core.model.DeviceType.TCP,
|
||||
org.meshtastic.core.model.DeviceType.USB,
|
||||
)
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
// Thread-safe StateFlow for tracking device address changes
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val logSends = false
|
||||
private val logReceives = false
|
||||
private lateinit var sentPacketsLog: BinaryLogFile
|
||||
private lateinit var receivedPacketsLog: BinaryLogFile
|
||||
|
||||
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
||||
/** We recreate this scope each time we stop an interface */
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
private var radioIf: RadioTransport = NopInterface("")
|
||||
|
||||
/**
|
||||
* true if we have started our interface
|
||||
*
|
||||
* Note: an interface may be started without necessarily yet having a connection
|
||||
*/
|
||||
private var isStarted = false
|
||||
|
||||
@Volatile private var listenersInitialized = false
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized) return
|
||||
synchronized(this) {
|
||||
if (listenersInitialized) return
|
||||
listenersInitialized = true
|
||||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (radioIf is BleRadioInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (radioIf is TCPInterface) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
|
||||
}
|
||||
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
if (radioIf is SerialInterface) {
|
||||
Logger.i { "Sending ToRadio heartbeat" }
|
||||
val heartbeat = ToRadio(heartbeat = Heartbeat())
|
||||
handleSendToRadio(heartbeat.encode())
|
||||
} else {
|
||||
// For BLE and TCP this will check if the connection is still alive
|
||||
radioIf.keepAlive()
|
||||
}
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
/** Constructs a full radio address for the specific interface type. */
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun getDeviceAddress(): String? {
|
||||
// If the user has unpaired our device, treat things as if we don't have one
|
||||
return _currentDeviceAddressFlow.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Like getDeviceAddress, but filtered to return only devices we are currently bonded with
|
||||
*
|
||||
* at
|
||||
*
|
||||
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
|
||||
* path)
|
||||
*/
|
||||
fun getBondedDeviceAddress(): String? {
|
||||
// If the user has unpaired our device, treat things as if we don't have one
|
||||
val address = getDeviceAddress()
|
||||
return if (interfaceFactory.value.addressValid(address)) {
|
||||
address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastConnectionChanged(newState: ConnectionState) {
|
||||
Logger.d { "Broadcasting connection state change to $newState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newState) }
|
||||
}
|
||||
|
||||
// Send a packet/command out the radio link, this routine can block if it needs to
|
||||
private fun handleSendToRadio(p: ByteArray) {
|
||||
radioIf.handleSendToRadio(p)
|
||||
emitSendActivity()
|
||||
}
|
||||
|
||||
// Handle an incoming packet from the radio, broadcasts it as an android intent
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
if (logReceives) {
|
||||
try {
|
||||
receivedPacketsLog.write(bytes)
|
||||
receivedPacketsLog.flush()
|
||||
} catch (t: Throwable) {
|
||||
Logger.w(t) { "Failed to write receive log in handleFromRadio" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
emitReceiveActivity()
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
broadcastConnectionChanged(ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
if (errorMessage != null) {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
|
||||
}
|
||||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
broadcastConnectionChanged(newTargetState)
|
||||
}
|
||||
}
|
||||
|
||||
/** Start our configured interface (if it isn't already running) */
|
||||
private fun startInterface() {
|
||||
if (radioIf !is NopInterface) {
|
||||
// Already running
|
||||
return
|
||||
}
|
||||
|
||||
val isTestLab = Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
val address =
|
||||
getBondedDeviceAddress()
|
||||
?: if (isTestLab) {
|
||||
mockInterfaceAddress
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No bonded mesh radio, can't start interface" }
|
||||
} else {
|
||||
Logger.i { "Starting radio ${address.anonymize}" }
|
||||
isStarted = true
|
||||
|
||||
if (logSends) {
|
||||
sentPacketsLog = BinaryLogFile(context, "sent_log.pb")
|
||||
}
|
||||
if (logReceives) {
|
||||
receivedPacketsLog = BinaryLogFile(context, "receive_log.pb")
|
||||
}
|
||||
|
||||
radioIf = interfaceFactory.value.createInterface(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
val r = radioIf
|
||||
Logger.i { "stopping interface $r" }
|
||||
isStarted = false
|
||||
radioIf = interfaceFactory.value.nopInterface
|
||||
r.close()
|
||||
|
||||
// cancel any old jobs and get ready for the new ones
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (logSends) {
|
||||
sentPacketsLog.close()
|
||||
}
|
||||
if (logReceives) {
|
||||
receivedPacketsLog.close()
|
||||
}
|
||||
|
||||
// Don't broadcast disconnects if we were just using the nop device
|
||||
if (r !is NopInterface) {
|
||||
onDisconnect(isPermanent = true) // Tell any clients we are now offline
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change to a new device
|
||||
*
|
||||
* @return true if the device changed, false if no change
|
||||
*/
|
||||
private fun setBondedDeviceAddress(address: String?): Boolean =
|
||||
if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "Ignoring setBondedDevice ${address.anonymize}, because we are already using that device" }
|
||||
false
|
||||
} else {
|
||||
// Record that this use has configured a new radio
|
||||
analytics.track("mesh_bond")
|
||||
|
||||
// Ignore any errors that happen while closing old device
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
// The device address "n" can be used to mean none
|
||||
|
||||
Logger.d { "Setting bonded device to ${address.anonymize}" }
|
||||
|
||||
// Stores the address if non-null, otherwise removes the pref
|
||||
radioPrefs.setDevAddr(address)
|
||||
_currentDeviceAddressFlow.value = address
|
||||
|
||||
// Force the service to reconnect
|
||||
startInterface()
|
||||
true
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
|
||||
setBondedDeviceAddress(deviceAddr)
|
||||
}
|
||||
|
||||
/**
|
||||
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
|
||||
* will not connect to a radio until this call is received.
|
||||
*/
|
||||
override fun connect() = toRemoteExceptions {
|
||||
// We don't start actually talking to our device until MeshService binds to us - this prevents
|
||||
// broadcasting connection events before MeshService is ready to receive them
|
||||
startInterface()
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
// Do this in the IO thread because it might take a while (and we don't care about the result code)
|
||||
_serviceScope.handledLaunch { handleSendToRadio(bytes) }
|
||||
}
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private fun emitSendActivity() {
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
|
||||
private fun emitReceiveActivity() {
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 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.network.radio
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
/** Android implementation of [RadioTransportFactory] delegating to the legacy [InterfaceFactory]. */
|
||||
@Single
|
||||
class AndroidRadioTransportFactory(
|
||||
private val context: Context,
|
||||
private val interfaceFactory: Lazy<InterfaceFactory>,
|
||||
private val buildConfigProvider: BuildConfigProvider,
|
||||
) : RadioTransportFactory {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
|
||||
|
||||
override fun isMockInterface(): Boolean =
|
||||
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
|
||||
|
||||
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
|
||||
interfaceFactory.value.createInterface(address, service)
|
||||
|
||||
override fun isAddressValid(address: String?): Boolean = interfaceFactory.value.addressValid(address)
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ class InterfaceFactory(
|
|||
} ?: false
|
||||
|
||||
private fun splitAddress(address: String): Pair<InterfaceSpec<*>?, String> {
|
||||
if (address.isEmpty()) return Pair(null, "")
|
||||
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] }
|
||||
val rest = address.substring(1)
|
||||
return Pair(c, rest)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import kotlinx.coroutines.sync.withLock
|
|||
* Implements the START1/START2 + 2-byte-length + payload framing protocol used for serial and TCP communication with
|
||||
* Meshtastic radios.
|
||||
*
|
||||
* Shared between Android (`StreamInterface`/`TCPInterface`) and Desktop (`DesktopRadioInterfaceService`).
|
||||
* Shared across Android, Desktop, and iOS via `SharedRadioInterfaceService`.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
class StreamFrameCodec(
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import java.net.SocketTimeoutException
|
|||
* Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff, heartbeat) and uses [StreamFrameCodec]
|
||||
* for the START1/START2 stream framing protocol.
|
||||
*
|
||||
* Used by both Android's `TCPInterface` and Desktop's `DesktopRadioInterfaceService`.
|
||||
* Used by Android and Desktop via the shared `SharedRadioInterfaceService`.
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "MagicNumber")
|
||||
class TcpTransport(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 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.repository
|
||||
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
|
||||
/**
|
||||
* Creates [RadioTransport] instances for specific device addresses.
|
||||
*
|
||||
* Implemented per-platform to provide the correct hardware transport (BLE, Serial, TCP).
|
||||
*/
|
||||
interface RadioTransportFactory {
|
||||
/** The device types supported by this factory. */
|
||||
val supportedDeviceTypes: List<DeviceType>
|
||||
|
||||
/** Whether we are currently forced into using a mock interface (e.g., Firebase Test Lab). */
|
||||
fun isMockInterface(): Boolean
|
||||
|
||||
/** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */
|
||||
fun createTransport(address: String, service: RadioInterfaceService): RadioTransport
|
||||
|
||||
/** Checks if the given [address] represents a valid, supported transport type. */
|
||||
fun isAddressValid(address: String?): Boolean
|
||||
|
||||
/** Constructs a full radio address for the specific [interfaceId] and [rest] identifier. */
|
||||
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String
|
||||
}
|
||||
|
|
@ -32,14 +32,19 @@ kotlin {
|
|||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(projects.core.repository)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.network)
|
||||
implementation(projects.core.ble)
|
||||
implementation(projects.core.prefs)
|
||||
implementation(projects.core.proto)
|
||||
|
||||
implementation(libs.jetbrains.lifecycle.runtime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kermit)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
/*
|
||||
* Copyright (c) 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.service
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BluetoothRepository
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.util.anonymize
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.RadioTransport
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
|
||||
/**
|
||||
* Shared multiplatform connection orchestrator for Meshtastic radios.
|
||||
*
|
||||
* Manages the connection lifecycle (connect, active, disconnect, reconnect loop), device address state flows, and
|
||||
* hardware state observability (BLE/Network toggles). Delegates the actual raw byte transport mapping to a
|
||||
* platform-specific [RadioTransportFactory].
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class SharedRadioInterfaceService(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val bluetoothRepository: BluetoothRepository,
|
||||
private val networkRepository: NetworkRepository,
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val transportFactory: RadioTransportFactory,
|
||||
private val analytics: PlatformAnalytics,
|
||||
) : RadioInterfaceService {
|
||||
|
||||
override val supportedDeviceTypes: List<DeviceType>
|
||||
get() = transportFactory.supportedDeviceTypes
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(radioPrefs.devAddr.value)
|
||||
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
|
||||
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
|
||||
override val receivedData: SharedFlow<ByteArray> = _receivedData
|
||||
|
||||
private val _meshActivity =
|
||||
MutableSharedFlow<MeshActivity>(
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
|
||||
|
||||
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
|
||||
|
||||
override val serviceScope: CoroutineScope
|
||||
get() = _serviceScope
|
||||
|
||||
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
private var radioIf: RadioTransport? = null
|
||||
private var isStarted = false
|
||||
|
||||
@Volatile private var listenersInitialized = false
|
||||
private var heartbeatJob: kotlinx.coroutines.Job? = null
|
||||
private var lastHeartbeatMillis = 0L
|
||||
|
||||
companion object {
|
||||
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
|
||||
}
|
||||
|
||||
private fun initStateListeners() {
|
||||
if (listenersInitialized) return
|
||||
synchronized(this) {
|
||||
if (listenersInitialized) return
|
||||
listenersInitialized = true
|
||||
|
||||
radioPrefs.devAddr
|
||||
.onEach { addr ->
|
||||
if (_currentDeviceAddressFlow.value != addr) {
|
||||
_currentDeviceAddressFlow.value = addr
|
||||
startInterface()
|
||||
}
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
bluetoothRepository.state
|
||||
.onEach { state ->
|
||||
if (state.enabled) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
|
||||
networkRepository.networkAvailable
|
||||
.onEach { state ->
|
||||
if (state) {
|
||||
startInterface()
|
||||
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
|
||||
stopInterface()
|
||||
}
|
||||
}
|
||||
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun connect() {
|
||||
startInterface()
|
||||
initStateListeners()
|
||||
}
|
||||
|
||||
override fun isMockInterface(): Boolean = transportFactory.isMockInterface()
|
||||
|
||||
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
|
||||
transportFactory.toInterfaceAddress(interfaceId, rest)
|
||||
|
||||
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
|
||||
|
||||
private fun getBondedDeviceAddress(): String? {
|
||||
val address = getDeviceAddress()
|
||||
return if (transportFactory.isAddressValid(address)) {
|
||||
address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(deviceAddr: String?): Boolean {
|
||||
val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr
|
||||
|
||||
if (getBondedDeviceAddress() == sanitized && isStarted && _connectionState.value == ConnectionState.Connected) {
|
||||
Logger.w { "Ignoring setBondedDevice ${sanitized?.anonymize}, already using that device" }
|
||||
return false
|
||||
}
|
||||
|
||||
analytics.track("mesh_bond")
|
||||
ignoreException { stopInterface() }
|
||||
|
||||
Logger.d { "Setting bonded device to ${sanitized?.anonymize}" }
|
||||
radioPrefs.setDevAddr(sanitized)
|
||||
_currentDeviceAddressFlow.value = sanitized
|
||||
startInterface()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun startInterface() {
|
||||
if (radioIf != null) return
|
||||
|
||||
val address =
|
||||
getBondedDeviceAddress()
|
||||
?: if (isMockInterface()) transportFactory.toInterfaceAddress(InterfaceId.MOCK, "") else null
|
||||
|
||||
if (address == null) {
|
||||
Logger.w { "No valid address to connect to." }
|
||||
return
|
||||
}
|
||||
|
||||
Logger.i { "Starting radio interface for ${address.anonymize}" }
|
||||
isStarted = true
|
||||
radioIf = transportFactory.createTransport(address, this)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
private fun stopInterface() {
|
||||
val currentIf = radioIf
|
||||
Logger.i { "Stopping interface $currentIf" }
|
||||
isStarted = false
|
||||
radioIf = null
|
||||
currentIf?.close()
|
||||
|
||||
_serviceScope.cancel("stopping interface")
|
||||
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
|
||||
if (currentIf != null) {
|
||||
onDisconnect(isPermanent = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
serviceScope.launch {
|
||||
while (true) {
|
||||
delay(HEARTBEAT_INTERVAL_MILLIS)
|
||||
keepAlive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun keepAlive(now: Long = nowMillis) {
|
||||
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
|
||||
radioIf?.keepAlive()
|
||||
lastHeartbeatMillis = now
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(bytes: ByteArray) {
|
||||
_serviceScope.handledLaunch {
|
||||
radioIf?.handleSendToRadio(bytes)
|
||||
_meshActivity.tryEmit(MeshActivity.Send)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun handleFromRadio(bytes: ByteArray) {
|
||||
try {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
|
||||
_meshActivity.tryEmit(MeshActivity.Receive)
|
||||
} catch (t: Throwable) {
|
||||
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnect() {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
Logger.d { "Broadcasting connection state change to Connected" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) {
|
||||
_connectionState.emit(ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
|
||||
if (errorMessage != null) {
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
|
||||
}
|
||||
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
|
||||
if (_connectionState.value != newTargetState) {
|
||||
Logger.d { "Broadcasting connection state change to $newTargetState" }
|
||||
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newTargetState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue