refactor: migrate core modules to Kotlin Multiplatform and consolidat… (#4735)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-06 16:06:50 -06:00 committed by GitHub
parent f3775a601c
commit cffbd08806
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
265 changed files with 1383 additions and 1340 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.data.datasource
import android.app.Application
@ -26,9 +25,10 @@ import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.BootloaderOtaQuirk
import javax.inject.Inject
class BootloaderOtaQuirksJsonDataSource @Inject constructor(private val application: Application) {
class BootloaderOtaQuirksJsonDataSourceImpl @Inject constructor(private val application: Application) :
BootloaderOtaQuirksJsonDataSource {
@OptIn(ExperimentalSerializationApi::class)
fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = runCatching {
override fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = runCatching {
val inputStream = application.assets.open("device_bootloader_ota_quirks.json")
inputStream.use { Json.decodeFromStream<ListWrapper>(it).devices }
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.data.datasource
import android.app.Application
@ -24,7 +23,8 @@ import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.NetworkDeviceHardware
import javax.inject.Inject
class DeviceHardwareJsonDataSource @Inject constructor(private val application: Application) {
class DeviceHardwareJsonDataSourceImpl @Inject constructor(private val application: Application) :
DeviceHardwareJsonDataSource {
// Use a tolerant JSON parser so that additional fields in the bundled asset
// (e.g., "key") do not break deserialization on older app versions.
@ -35,7 +35,7 @@ class DeviceHardwareJsonDataSource @Inject constructor(private val application:
}
@OptIn(ExperimentalSerializationApi::class)
fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware> =
override fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware> =
application.assets.open("device_hardware.json").use { inputStream ->
json.decodeFromStream<List<NetworkDeviceHardware>>(inputStream)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.data.datasource
import android.app.Application
@ -24,7 +23,8 @@ import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.NetworkFirmwareReleases
import javax.inject.Inject
class FirmwareReleaseJsonDataSource @Inject constructor(private val application: Application) {
class FirmwareReleaseJsonDataSourceImpl @Inject constructor(private val application: Application) :
FirmwareReleaseJsonDataSource {
// Match the network client behavior: be tolerant of unknown fields so that
// older app versions can read newer snapshots of firmware_releases.json.
@ -35,7 +35,7 @@ class FirmwareReleaseJsonDataSource @Inject constructor(private val application:
}
@OptIn(ExperimentalSerializationApi::class)
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases =
override fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases =
application.assets.open("firmware_releases.json").use { inputStream ->
json.decodeFromStream<NetworkFirmwareReleases>(inputStream)
}

View file

@ -20,6 +20,7 @@ import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.app.Application
import android.location.LocationManager
import android.os.Build
import androidx.annotation.RequiresPermission
import androidx.core.location.LocationCompat
import androidx.core.location.LocationListenerCompat
@ -29,55 +30,61 @@ import androidx.core.location.altitude.AltitudeConverterCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.PlatformAnalytics
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationRepository
class LocationRepositoryImpl
@Inject
constructor(
private val context: Application,
private val locationManager: dagger.Lazy<LocationManager>,
private val analytics: PlatformAnalytics,
private val dispatchers: CoroutineDispatchers,
) {
) : LocationRepository {
companion object {
private const val DEFAULT_INTERVAL_MS = 30_000L
private const val MIN_DISTANCE_METERS = 0f
private const val API_LEVEL_31 = 31
}
/** Status of whether the app is actively subscribed to location changes. */
private val _receivingLocationUpdates: MutableStateFlow<Boolean> = MutableStateFlow(false)
val receivingLocationUpdates: StateFlow<Boolean>
override val receivingLocationUpdates: StateFlow<Boolean>
get() = _receivingLocationUpdates
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
private fun LocationManager.requestLocationUpdates() = callbackFlow {
val intervalMs = 30 * 1000L // 30 seconds
val minDistanceM = 0f
private fun LocationManager.requestLocationUpdates(): Flow<Location> = callbackFlow {
val locationRequest =
LocationRequestCompat.Builder(intervalMs)
.setMinUpdateDistanceMeters(minDistanceM)
LocationRequestCompat.Builder(DEFAULT_INTERVAL_MS)
.setMinUpdateDistanceMeters(MIN_DISTANCE_METERS)
.setQuality(LocationRequestCompat.QUALITY_HIGH_ACCURACY)
.build()
val locationListener = LocationListenerCompat { location ->
if (location.hasAltitude() && !LocationCompat.hasMslAltitude(location)) {
@Suppress("TooGenericExceptionCaught")
try {
AltitudeConverterCompat.addMslAltitudeToLocation(context, location)
} catch (e: Exception) {
Logger.e(e) { "addMslAltitudeToLocation() failed" }
}
}
// info("New location: $location")
trySend(location)
}
val providerList = buildList {
val providers = allProviders
if (android.os.Build.VERSION.SDK_INT >= 31 && LocationManager.FUSED_PROVIDER in providers) {
if (Build.VERSION.SDK_INT >= API_LEVEL_31 && LocationManager.FUSED_PROVIDER in providers) {
add(LocationManager.FUSED_PROVIDER)
} else {
if (LocationManager.GPS_PROVIDER in providers) add(LocationManager.GPS_PROVIDER)
@ -86,11 +93,13 @@ constructor(
}
Logger.i {
"Starting location updates with $providerList intervalMs=${intervalMs}ms and minDistanceM=${minDistanceM}m"
"Starting location updates with $providerList intervalMs=$DEFAULT_INTERVAL_MS " +
"and minDistanceM=$MIN_DISTANCE_METERS"
}
_receivingLocationUpdates.value = true
analytics.track("location_start") // Figure out how many users needed to use the phone GPS
analytics.track("location_start")
@Suppress("TooGenericExceptionCaught")
try {
providerList.forEach { provider ->
LocationManagerCompat.requestLocationUpdates(
@ -102,7 +111,7 @@ constructor(
)
}
} catch (e: Exception) {
close(e) // in case of exception, close the Flow
close(e)
}
awaitClose {
@ -116,5 +125,5 @@ constructor(
/** Observable flow for location updates */
@RequiresPermission(anyOf = [ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION])
fun getLocations() = locationManager.get().requestLocationUpdates()
override fun getLocations(): Flow<Location> = locationManager.get().requestLocationUpdates()
}

View file

@ -14,18 +14,10 @@
* 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.data.model
package org.meshtastic.core.data.datasource
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
import org.meshtastic.core.model.BootloaderOtaQuirk
@Serializable
data class CustomTileProviderConfig(
val id: String = Uuid.random().toString(),
val name: String,
val urlTemplate: String,
val localUri: String? = null,
) {
val isLocal: Boolean
get() = localUri != null
interface BootloaderOtaQuirksJsonDataSource {
fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk>
}

View file

@ -14,21 +14,10 @@
* 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.data.di
package org.meshtastic.core.data.datasource
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.meshtastic.core.model.NetworkDeviceHardware
@InstallIn(SingletonComponent::class)
@Module
interface DatabaseModule {
@Binds
@Singleton
fun bindDatabaseManager(
impl: org.meshtastic.core.database.DatabaseManager,
): org.meshtastic.core.common.database.DatabaseManager
interface DeviceHardwareJsonDataSource {
fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware>
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,24 +14,10 @@
* 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.data.datasource
package org.meshtastic.core.data.di
import org.meshtastic.core.model.NetworkFirmwareReleases
import android.content.Context
import android.location.LocationManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideLocationManager(@ApplicationContext context: Context): LocationManager =
context.applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
interface FirmwareReleaseJsonDataSource {
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.data.datasource
import kotlinx.coroutines.flow.Flow

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -45,12 +46,10 @@ import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@ -63,10 +62,10 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
override val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
override val neighborInfoStartTimes = ConcurrentHashMap<Int, Long>()
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
override val neighborInfoStartTimes = mutableMapOf<Int, Long>()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
@ -87,7 +86,7 @@ constructor(
override fun getCachedChannelSet(): ChannelSet = channelSet.value
override fun getCurrentPacketId(): Long = currentPacketId.get()
override fun getCurrentPacketId(): Long = currentPacketId.value
override fun generatePacketId(): Int {
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
@ -96,7 +95,7 @@ constructor(
}
override fun setSessionPasskey(key: ByteString) {
sessionPasskey.set(key)
sessionPasskey.value = key
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
@ -167,7 +166,7 @@ constructor(
}
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.get())
val adminMsg = initFn().copy(session_passkey = sessionPasskey.value)
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
packetHandler.sendToRadio(packet)

View file

@ -21,8 +21,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
@ -34,6 +32,7 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
@ -41,6 +40,7 @@ import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel

View file

@ -22,7 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import okio.IOException
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
@ -31,6 +31,7 @@ import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
@ -39,7 +40,6 @@ import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo

View file

@ -28,8 +28,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -37,6 +35,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
@ -47,6 +46,7 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts

View file

@ -24,9 +24,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import okio.IOException
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -39,6 +41,7 @@ import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
@ -50,6 +53,7 @@ import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
@ -72,8 +76,6 @@ import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@ -355,11 +357,11 @@ constructor(
u.session_passkey.let { commandSender.setSessionPasskey(it) }
val fromNum = packet.from
u.get_module_config_response?.let { config ->
u.get_module_config_response?.let {
if (fromNum == myNodeNum) {
configHandler.get().handleModuleConfig(config)
configHandler.get().handleModuleConfig(it)
} else {
config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
@ -368,11 +370,11 @@ constructor(
u.get_channel_response?.let { configHandler.get().handleChannel(it) }
}
u.get_device_metadata_response?.let { metadata ->
u.get_device_metadata_response?.let {
if (fromNum == myNodeNum) {
configFlowManager.get().handleLocalMetadata(metadata)
configFlowManager.get().handleLocalMetadata(it)
} else {
nodeManager.insertMetadata(fromNum, metadata)
nodeManager.insertMetadata(fromNum, it)
}
}
}
@ -429,14 +431,20 @@ constructor(
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
scope.launch {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
}
}
} else {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
scope.launch {
batteryMutex.withLock {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
}
}
}
@ -451,7 +459,7 @@ constructor(
}
@Suppress("ReturnCount")
private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
var forceDisplay = false
@ -470,10 +478,12 @@ constructor(
}
if (shouldDisplay) {
val now = nowSeconds
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
batteryMutex.withLock {
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
}
}
}
return false
@ -775,6 +785,7 @@ constructor(
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
private val batteryPercentCooldowns = ConcurrentHashMap<Int, Long>()
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
}
}

View file

@ -24,6 +24,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -40,9 +43,6 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import java.util.ArrayDeque
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
@ -60,14 +60,17 @@ constructor(
private val fromRadioDispatcher: FromRadioPacketHandler,
) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val logUuidByPacketId = ConcurrentHashMap<Int, String>()
private val logInsertJobByPacketId = ConcurrentHashMap<Int, Job>()
private val earlyReceivedPackets = ArrayDeque<MeshPacket>()
private val mapsMutex = Mutex()
private val logUuidByPacketId = mutableMapOf<Int, String>()
private val logInsertJobByPacketId = mutableMapOf<Int, Job>()
private val earlyMutex = Mutex()
private val earlyReceivedPackets = kotlin.collections.ArrayDeque<MeshPacket>()
private val maxEarlyPacketBuffer = 10240
override fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } }
}
override fun start(scope: CoroutineScope) {
@ -91,8 +94,7 @@ constructor(
}
.onFailure { _ ->
Logger.e(primaryException) {
"Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " +
"Not a valid FromRadio or LogRecord."
"Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
}
}
}
@ -150,27 +152,33 @@ constructor(
if (nodeManager.isNodeDbReady.value) {
processReceivedMeshPacket(preparedPacket, myNodeNum)
} else {
synchronized(earlyReceivedPackets) {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
earlyReceivedPackets.removeFirst()
scope.launch {
earlyMutex.withLock {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
earlyReceivedPackets.removeFirstOrNull()
}
earlyReceivedPackets.addLast(preparedPacket)
}
earlyReceivedPackets.addLast(preparedPacket)
}
}
}
private fun flushEarlyReceivedPackets(reason: String) {
val packets =
synchronized(earlyReceivedPackets) {
if (earlyReceivedPackets.isEmpty()) return
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
scope.launch {
val packets =
earlyMutex.withLock {
if (earlyReceivedPackets.isEmpty()) return@withLock emptyList<MeshPacket>()
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
if (packets.isEmpty()) return@launch
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
}
@Suppress("LongMethod")
@ -187,8 +195,13 @@ constructor(
fromRadio = FromRadio(packet = packet),
)
val logJob = insertMeshLog(log)
logInsertJobByPacketId[packet.id] = logJob
logUuidByPacketId[packet.id] = log.uuid
scope.launch {
mapsMutex.withLock {
logInsertJobByPacketId[packet.id] = logJob
logUuidByPacketId[packet.id] = log.uuid
}
}
scope.handledLaunch { serviceRepository.emitMeshPacket(packet) }
@ -235,14 +248,15 @@ constructor(
try {
router.get().dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
} finally {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
scope.launch {
mapsMutex.withLock {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
}
}
}
}
}
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) }
private fun ByteArray.toHexString(): String =
this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
}

View file

@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
import javax.inject.Singleton
@ -49,7 +48,7 @@ class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs
} else {
Regex("\\b${Regex.escape(word)}\\b", RegexOption.IGNORE_CASE)
}
} catch (e: PatternSyntaxException) {
} catch (e: IllegalArgumentException) {
Logger.w { "Invalid filter pattern: $word - ${e.message}" }
null
}

View file

@ -17,6 +17,9 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -42,21 +45,12 @@ import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
/**
* Implementation of [NodeManager] that maintains an in-memory database of the mesh.
*
* This component acts as the "brain" for node-related data during a connection session. It manages:
* 1. In-memory maps for fast node lookup by number or ID.
* 2. Synchronization of node data between the radio and the persistent database.
* 3. Processing of incoming node-related packets (User, Position, Telemetry).
* 4. Broadcasting changes to the rest of the application.
*/
/** Implementation of [NodeManager] that maintains an in-memory database of the mesh. */
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class NodeManagerImpl
@ -68,8 +62,14 @@ constructor(
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override val nodeDBbyNodeNum = ConcurrentHashMap<Int, Node>()
override val nodeDBbyID = ConcurrentHashMap<String, Node>()
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
override val nodeDBbyNodeNum: Map<Int, Node>
get() = _nodeDBbyNodeNum.value
override val nodeDBbyID: Map<String, Node>
get() = _nodeDBbyID.value
override val isNodeDbReady = MutableStateFlow(false)
override val allowNodeDbWrites = MutableStateFlow(false)
@ -95,15 +95,17 @@ constructor(
override fun loadCachedNodeDB() {
scope.handledLaunch {
val nodes = nodeRepository.nodeDBbyNum.first()
nodeDBbyNodeNum.putAll(nodes)
nodes.values.forEach { nodeDBbyID[it.user.id] = it }
_nodeDBbyNodeNum.value = persistentMapOf<Int, Node>().putAll(nodes)
val byId = mutableMapOf<String, Node>()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
}
}
override fun clear() {
nodeDBbyNodeNum.clear()
nodeDBbyID.clear()
_nodeDBbyNodeNum.value = persistentMapOf()
_nodeDBbyID.value = persistentMapOf()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum = null
@ -111,7 +113,7 @@ constructor(
override fun getMyNodeInfo(): MyNodeInfo? {
val mi = nodeRepository.myNodeInfo.value ?: return null
val myNode = nodeDBbyNodeNum[mi.myNodeNum]
val myNode = _nodeDBbyNodeNum.value[mi.myNodeNum]
return MyNodeInfo(
myNodeNum = mi.myNodeNum,
hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
@ -132,34 +134,41 @@ constructor(
override fun getMyId(): String {
val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
return nodeDBbyNodeNum[num]?.user?.id ?: ""
return _nodeDBbyNodeNum.value[num]?.user?.id ?: ""
}
override fun getNodes(): List<NodeInfo> = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
override fun getNodes(): List<NodeInfo> = _nodeDBbyNodeNum.value.values.map { it.toNodeInfo() }
override fun removeByNodenum(nodeNum: Int) {
nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
val removed = atomic<Node?>(null)
_nodeDBbyNodeNum.update { map ->
val node = map[nodeNum]
removed.value = node
map.remove(nodeNum)
}
removed.value?.let { node -> _nodeDBbyID.update { it.remove(node.user.id) } }
}
fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeDBbyNodeNum.getOrPut(n) {
val userId = DataPacket.nodeNumToDefaultId(n)
val defaultUser =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(n = 4)}",
short_name = userId.takeLast(n = 4),
hw_model = HardwareModel.UNSET,
)
internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNodeNum.value[n]
?: run {
val userId = DataPacket.nodeNumToDefaultId(n)
val defaultUser =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(n = 4)}",
short_name = userId.takeLast(n = 4),
hw_model = HardwareModel.UNSET,
)
Node(num = n, user = defaultUser, channel = channel)
}
Node(num = n, user = defaultUser, channel = channel)
}
override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
val current = nodeDBbyNodeNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
val next = transform(current)
nodeDBbyNodeNum[nodeNum] = next
val next = transform(_nodeDBbyNodeNum.value[nodeNum] ?: getOrCreateNode(nodeNum, channel))
_nodeDBbyNodeNum.update { it.put(nodeNum, next) }
if (next.user.id.isNotEmpty()) {
nodeDBbyID[next.user.id] = next
_nodeDBbyID.update { it.put(next.user.id, next) }
}
if (next.user.id.isNotEmpty() && isNodeDbReady.value) {
@ -252,7 +261,8 @@ constructor(
if (shouldPreserveExistingUser(node.user, user)) {
// keep existing names
} else {
var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
var newUser =
user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
if (info.via_mqtt) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
}
@ -292,7 +302,7 @@ constructor(
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
_nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
}
private fun Node.toNodeInfo(): NodeInfo = NodeInfo(

View file

@ -24,6 +24,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
@ -45,8 +48,6 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@ -72,8 +73,11 @@ constructor(
private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private val queuedPackets = ConcurrentLinkedQueue<MeshPacket>()
private val queueResponse = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
override fun start(scope: CoroutineScope) {
this.scope = scope
@ -103,8 +107,10 @@ constructor(
}
override fun sendToRadio(packet: MeshPacket) {
queuedPackets.add(packet)
startPacketQueue()
scope.launch {
queueMutex.withLock { queuedPackets.add(packet) }
startPacketQueue()
}
}
override fun stopPacketQueue() {
@ -112,9 +118,13 @@ constructor(
Logger.i { "Stopping packet queueJob" }
queueJob?.cancel()
queueJob = null
queuedPackets.clear()
queueResponse.entries.lastOrNull { !it.value.isCompleted }?.value?.complete(false)
queueResponse.clear()
scope.launch {
queueMutex.withLock { queuedPackets.clear() }
responseMutex.withLock {
queueResponse.values.lastOrNull { !it.isCompleted }?.complete(false)
queueResponse.clear()
}
}
}
}
@ -122,15 +132,20 @@ constructor(
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
if (success && isFull) return
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
scope.launch {
responseMutex.withLock {
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
}
}
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
queueResponse.remove(dataRequestId)?.complete(complete)
scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } }
}
private fun startPacketQueue() {
@ -138,20 +153,27 @@ constructor(
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queuedPackets.poll() ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
} finally {
queueResponse.remove(packet.id)
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
} finally {
responseMutex.withLock { queueResponse.remove(packet.id) }
}
}
} finally {
queueJob = null
if (queueMutex.withLock { queuedPackets.isNotEmpty() }) {
startPacketQueue()
}
}
}
@ -177,9 +199,9 @@ constructor(
}
@Suppress("TooGenericExceptionCaught")
private fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
private suspend fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
val deferred = CompletableDeferred<Boolean>()
queueResponse[packet.id] = deferred
responseMutex.withLock { queueResponse[packet.id] = deferred }
try {
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,7 +14,6 @@
* 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.data.repository
import kotlinx.coroutines.flow.flatMapLatest

View file

@ -31,7 +31,6 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
@ -47,6 +46,7 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts

View file

@ -28,7 +28,6 @@ import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.MeshDataMapper
@ -43,6 +42,7 @@ import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2025 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.data.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.CustomTileProviderRepositoryImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface GoogleDataModule {
@Binds
@Singleton
fun bindCustomTileProviderRepository(impl: CustomTileProviderRepositoryImpl): CustomTileProviderRepository
companion object {
@Provides @Singleton
fun provideJson(): Json = Json { prettyPrint = false }
}
}

View file

@ -1,107 +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.data.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapTileProviderPrefs
import javax.inject.Inject
import javax.inject.Singleton
interface CustomTileProviderRepository {
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
suspend fun deleteCustomTileProvider(configId: String)
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
}
@Singleton
class CustomTileProviderRepositoryImpl
@Inject
constructor(
private val json: Json,
private val dispatchers: CoroutineDispatchers,
private val mapTileProviderPrefs: MapTileProviderPrefs,
) : CustomTileProviderRepository {
private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
init {
loadDataFromPrefs()
}
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
customTileProvidersStateFlow.asStateFlow()
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value + config
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun deleteCustomTileProvider(configId: String) {
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
customTileProvidersStateFlow.value.find { it.id == configId }
private fun loadDataFromPrefs() {
val jsonString = mapTileProviderPrefs.customTileProviders.value
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error deserializing tile providers" }
customTileProvidersStateFlow.value = emptyList()
}
} else {
customTileProvidersStateFlow.value = emptyList()
}
}
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}
}
}
}

View file

@ -1,165 +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.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.meshtastic.core.datastore.KEY_APP_INTRO_COMPLETED
import org.meshtastic.core.datastore.KEY_INCLUDE_UNKNOWN
import org.meshtastic.core.datastore.KEY_NODE_SORT
import org.meshtastic.core.datastore.KEY_ONLY_DIRECT
import org.meshtastic.core.datastore.KEY_ONLY_ONLINE
import org.meshtastic.core.datastore.KEY_SHOW_IGNORED
import org.meshtastic.core.datastore.KEY_THEME
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
import javax.inject.Qualifier
import javax.inject.Singleton
private const val USER_PREFERENCES_NAME = "user_preferences"
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class DataStoreScope
@InstallIn(SingletonComponent::class)
@Module
object DataStoreModule {
@Provides
@Singleton
@DataStoreScope
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Singleton
@Provides
fun providePreferencesDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
listOf(
SharedPreferencesMigration(context = appContext, sharedPreferencesName = USER_PREFERENCES_NAME),
SharedPreferencesMigration(
context = appContext,
sharedPreferencesName = "ui-prefs",
keysToMigrate =
setOf(
KEY_APP_INTRO_COMPLETED,
KEY_THEME,
KEY_NODE_SORT,
KEY_INCLUDE_UNKNOWN,
KEY_ONLY_ONLINE,
KEY_ONLY_DIRECT,
KEY_SHOW_IGNORED,
),
),
),
scope = scope,
produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) },
)
@Singleton
@Provides
fun provideLocalConfigDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalConfig> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = LocalConfigSerializer,
producePath = { appContext.dataStoreFile("local_config.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
scope = scope,
)
@Singleton
@Provides
fun provideModuleConfigDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalModuleConfig> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = ModuleConfigSerializer,
producePath = { appContext.dataStoreFile("module_config.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
scope = scope,
)
@Singleton
@Provides
fun provideChannelSetDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<ChannelSet> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = ChannelSetSerializer,
producePath = { appContext.dataStoreFile("channel_set.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
@Singleton
@Provides
fun provideLocalStatsDataStore(
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalStats> = DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = LocalStatsSerializer,
producePath = { appContext.dataStoreFile("local_stats.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
scope = scope,
)
}

View file

@ -1,38 +0,0 @@
/*
* Copyright (c) 2025 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.data.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.data.datasource.SwitchingNodeInfoReadDataSource
import org.meshtastic.core.data.datasource.SwitchingNodeInfoWriteDataSource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface NodeDataSourceModule {
@Binds @Singleton
fun bindNodeInfoReadDataSource(impl: SwitchingNodeInfoReadDataSource): NodeInfoReadDataSource
@Binds @Singleton
fun bindNodeInfoWriteDataSource(impl: SwitchingNodeInfoWriteDataSource): NodeInfoWriteDataSource
}

View file

@ -1,157 +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.data.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.manager.CommandSenderImpl
import org.meshtastic.core.data.manager.FromRadioPacketHandlerImpl
import org.meshtastic.core.data.manager.HistoryManagerImpl
import org.meshtastic.core.data.manager.MeshActionHandlerImpl
import org.meshtastic.core.data.manager.MeshConfigFlowManagerImpl
import org.meshtastic.core.data.manager.MeshConfigHandlerImpl
import org.meshtastic.core.data.manager.MeshConnectionManagerImpl
import org.meshtastic.core.data.manager.MeshDataHandlerImpl
import org.meshtastic.core.data.manager.MeshMessageProcessorImpl
import org.meshtastic.core.data.manager.MeshRouterImpl
import org.meshtastic.core.data.manager.MessageFilterImpl
import org.meshtastic.core.data.manager.MqttManagerImpl
import org.meshtastic.core.data.manager.NeighborInfoHandlerImpl
import org.meshtastic.core.data.manager.NodeManagerImpl
import org.meshtastic.core.data.manager.PacketHandlerImpl
import org.meshtastic.core.data.manager.TracerouteHandlerImpl
import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl
import org.meshtastic.core.data.repository.MeshLogRepositoryImpl
import org.meshtastic.core.data.repository.NodeRepositoryImpl
import org.meshtastic.core.data.repository.PacketRepositoryImpl
import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindNodeRepository(nodeRepositoryImpl: NodeRepositoryImpl): NodeRepository
@Binds
@Singleton
abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository
@Binds
@Singleton
abstract fun bindDeviceHardwareRepository(
deviceHardwareRepositoryImpl: DeviceHardwareRepositoryImpl,
): DeviceHardwareRepository
@Binds @Singleton
abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
@Binds
@Singleton
abstract fun bindMeshLogRepository(meshLogRepositoryImpl: MeshLogRepositoryImpl): MeshLogRepository
@Binds @Singleton
abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager
@Binds @Singleton
abstract fun bindCommandSender(commandSenderImpl: CommandSenderImpl): CommandSender
@Binds @Singleton
abstract fun bindHistoryManager(historyManagerImpl: HistoryManagerImpl): HistoryManager
@Binds
@Singleton
abstract fun bindTracerouteHandler(tracerouteHandlerImpl: TracerouteHandlerImpl): TracerouteHandler
@Binds
@Singleton
abstract fun bindNeighborInfoHandler(neighborInfoHandlerImpl: NeighborInfoHandlerImpl): NeighborInfoHandler
@Binds @Singleton
abstract fun bindMqttManager(mqttManagerImpl: MqttManagerImpl): MqttManager
@Binds @Singleton
abstract fun bindPacketHandler(packetHandlerImpl: PacketHandlerImpl): PacketHandler
@Binds
@Singleton
abstract fun bindMeshConnectionManager(meshConnectionManagerImpl: MeshConnectionManagerImpl): MeshConnectionManager
@Binds @Singleton
abstract fun bindMeshDataHandler(meshDataHandlerImpl: MeshDataHandlerImpl): MeshDataHandler
@Binds
@Singleton
abstract fun bindMeshActionHandler(meshActionHandlerImpl: MeshActionHandlerImpl): MeshActionHandler
@Binds
@Singleton
abstract fun bindMeshMessageProcessor(meshMessageProcessorImpl: MeshMessageProcessorImpl): MeshMessageProcessor
@Binds @Singleton
abstract fun bindMeshRouter(meshRouterImpl: MeshRouterImpl): MeshRouter
@Binds
@Singleton
abstract fun bindFromRadioPacketHandler(
fromRadioPacketHandlerImpl: FromRadioPacketHandlerImpl,
): FromRadioPacketHandler
@Binds
@Singleton
abstract fun bindMeshConfigHandler(meshConfigHandlerImpl: MeshConfigHandlerImpl): MeshConfigHandler
@Binds
@Singleton
abstract fun bindMeshConfigFlowManager(meshConfigFlowManagerImpl: MeshConfigFlowManagerImpl): MeshConfigFlowManager
@Binds @Singleton
abstract fun bindMessageFilter(messageFilterImpl: MessageFilterImpl): MessageFilter
companion object {
@Provides
@Singleton
fun provideMeshDataMapper(nodeManager: NodeManager): MeshDataMapper = MeshDataMapper(nodeManager)
}
}

View file

@ -1,45 +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.data.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
@Provides
@Singleton
fun provideSendMessageUseCase(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
homoglyphEncodingPrefs: HomoglyphPrefs,
messageQueue: MessageQueue,
): SendMessageUseCase =
SendMessageUseCase(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue)
}