Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ kotlin {
implementation(libs.kermit)
}
androidMain.dependencies {
implementation(libs.androidx.core.ktx)
api(libs.androidx.core.ktx)
api(libs.nordic.common.core)
}
commonTest.dependencies {
Expand Down
1 change: 1 addition & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies {
// Needed because core:data references MeshtasticDatabase (supertype RoomDatabase)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.sqlite.bundled)

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@
*/
package org.meshtastic.core.data.datasource

import dagger.Lazy
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.di.CoroutineDispatchers
Expand All @@ -28,10 +27,11 @@ import javax.inject.Inject
class DeviceHardwareLocalDataSource
@Inject
constructor(
private val deviceHardwareDaoLazy: Lazy<DeviceHardwareDao>,
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) {
private val deviceHardwareDao by lazy { deviceHardwareDaoLazy.get() }
private val deviceHardwareDao
get() = dbManager.currentDb.value.deviceHardwareDao()

suspend fun insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) =
withContext(dispatchers.io) { deviceHardwareDao.insertAll(deviceHardware.map { it.asEntity() }) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@
*/
package org.meshtastic.core.data.datasource

import dagger.Lazy
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asDeviceVersion
Expand All @@ -30,10 +29,11 @@ import javax.inject.Inject
class FirmwareReleaseLocalDataSource
@Inject
constructor(
private val firmwareReleaseDaoLazy: Lazy<FirmwareReleaseDao>,
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) {
private val firmwareReleaseDao by lazy { firmwareReleaseDaoLazy.get() }
private val firmwareReleaseDao
get() = dbManager.currentDb.value.firmwareReleaseDao()

suspend fun insertFirmwareReleases(
firmwareReleases: List<NetworkFirmwareRelease>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.database.DatabaseManager
import javax.inject.Singleton

@InstallIn(SingletonComponent::class)
@Module
interface DatabaseModule {

@Binds @Singleton
fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,12 @@ constructor(
val currentPosition =
when {
provideLocation && position.isValid() -> position
else ->
provideLocation ->
nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
?: Position(0.0, 0.0, 0)
else -> Position(0.0, 0.0, 0)
}
currentPosition?.let { commandSender.requestPosition(destNum, it) }
commandSender.requestPosition(destNum, currentPosition)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,11 +440,13 @@ constructor(
}
}
}

environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
power != null -> nextNode = nextNode.copy(powerMetrics = power)
}
nextNode

val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard
val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime)
nextNode.copy(lastHeard = newLastHeard)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,23 +192,43 @@ constructor(
}

override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) {
if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
Logger.d { "Ignoring nop position update for the local node" }
} else {
updateNode(fromNum) { node ->
node.copy(position = p.copy(time = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt()))
}
val isZeroPos = (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0
@Suppress("ComplexCondition")
if (myNodeNum == fromNum && isZeroPos && p.sats_in_view == 0 && p.time == 0) {
Logger.d { "Ignoring empty position update for the local node" }
return
}

updateNode(fromNum) { node ->
val posTime = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt()
val newLastHeard = maxOf(node.lastHeard, posTime)

val newPos =
if (isZeroPos) {
p.copy(
time = posTime,
latitude_i = node.position.latitude_i,
longitude_i = node.position.longitude_i,
altitude = p.altitude ?: node.position.altitude,
sats_in_view = p.sats_in_view,
)
} else {
p.copy(time = posTime)
}

node.copy(position = newPos, lastHeard = newLastHeard)
}
}

override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
updateNode(fromNum) { node ->
when {
telemetry.device_metrics != null -> node.copy(deviceMetrics = telemetry.device_metrics!!)
telemetry.environment_metrics != null -> node.copy(environmentMetrics = telemetry.environment_metrics!!)
telemetry.power_metrics != null -> node.copy(powerMetrics = telemetry.power_metrics!!)
else -> node
}
var nextNode = node
telemetry.device_metrics?.let { nextNode = nextNode.copy(deviceMetrics = it) }
telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) }
telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) }
val telemetryTime = if (telemetry.time != 0) telemetry.time else node.lastHeard
val newLastHeard = maxOf(node.lastHeard, telemetryTime)
nextNode.copy(lastHeard = newLastHeard)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ constructor(
num = num,
user = user,
position = position,
latitude = latitude,
longitude = longitude,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,85 @@ class NodeManagerImplTest {
assertEquals(90.0, result.longitude, 0.0001)
}

@Test
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
val nodeNum = 1234
val initialPosition = Position(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)

// Receive "zero" position with new satellite count
val zeroPosition = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)

val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals(45.0, result!!.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
assertEquals(5, result.position.sats_in_view)
assertEquals(1001, result.lastHeard)
}

@Test
fun `handleReceivedPosition for local node ignores purely empty packets`() {
val myNum = 1111
val emptyPos = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)

nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)

val result = nodeManager.nodeDBbyNodeNum[myNum]
// Should still be a default/unset node if it didn't exist, or shouldn't have position
assertTrue(result == null || result.position.latitude_i == null)
}

@Test
fun `handleReceivedTelemetry updates lastHeard`() {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }

val telemetry =
org.meshtastic.proto.Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50),
)

nodeManager.handleReceivedTelemetry(nodeNum, telemetry)

val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals(2000, result!!.lastHeard)
}

@Test
fun `handleReceivedTelemetry updates device metrics`() {
val nodeNum = 1234
val telemetry =
org.meshtastic.proto.Telemetry(
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f),
)

nodeManager.handleReceivedTelemetry(nodeNum, telemetry)

val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.deviceMetrics)
assertEquals(75, result.deviceMetrics.battery_level)
assertEquals(3.8f, result.deviceMetrics.voltage)
}

@Test
fun `handleReceivedTelemetry updates environment metrics`() {
val nodeNum = 1234
val telemetry =
org.meshtastic.proto.Telemetry(
environment_metrics =
org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f),
)

nodeManager.handleReceivedTelemetry(nodeNum, telemetry)

val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.environmentMetrics)
assertEquals(22.5f, result.environmentMetrics.temperature)
assertEquals(45.0f, result.environmentMetrics.relative_humidity)
}

@Test
fun `clear resets internal state`() {
nodeManager.updateNode(1234) { it.copy(user = it.user.copy(long_name = "Test")) }
Expand Down
10 changes: 5 additions & 5 deletions core/database/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# `:core:database`

This module provides the local Room database persistence layer for the application.
This module provides the local Room database persistence layer for the application using Room Kotlin Multiplatform (KMP).

## Key Components

- **`MeshtasticDatabase`**: The main Room database class.
- **`MeshtasticDatabase`**: The main Room database class, defined in `commonMain`.
- **DAOs (Data Access Objects)**:
- `NodeInfoDao`: Manages storage and retrieval of node information (`NodeEntity`). Contains critical logic for handling Public Key Conflict (PKC) resolution and preventing identity wiping attacks.
- `PacketDao`: Handles storage of mesh packets.
- `ChatMessageDao`: Manages chat message history.
- `PacketDao`: Handles storage of mesh packets, including text messages, waypoints, and reactions.
- **Entities**:
- `NodeEntity`: Represents a node on the mesh.
- `PacketEntity`: Represents a stored packet.
- `Packet`: Represents a stored packet.
- `ReactionEntity`: Represents emoji reactions to packets.

## Security Considerations

Expand Down
76 changes: 46 additions & 30 deletions core/database/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,61 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension

plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.android.room)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.kotlin.parcelize)
}

configure<LibraryExtension> {
namespace = "org.meshtastic.core.database"
kotlin {
android {
namespace = "org.meshtastic.core.database"
withHostTest { isIncludeAndroidResources = true }
withDeviceTest { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
}

sourceSets {
// Adds exported schema location as test app assets.
named("androidTest") { assets.directories.add("$projectDir/schemas") }
commonMain.dependencies {
implementation(libs.androidx.sqlite.bundled)
implementation(projects.core.repository)
api(projects.core.common)
implementation(projects.core.di)
api(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.resources)
implementation(libs.androidx.room.paging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
}
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.room.testing)
}
androidMain.dependencies { implementation(libs.javax.inject) }

val androidHostTest by getting {
dependencies {
implementation(libs.androidx.room.testing)
implementation(libs.androidx.test.core)
implementation(libs.androidx.test.ext.junit)
implementation(libs.junit)
implementation(libs.robolectric)
}
}
val androidDeviceTest by getting {
dependencies {
implementation(libs.androidx.room.testing)
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.runner)
}
resources.srcDir("$projectDir/schemas")
}
}
}

dependencies {
implementation(projects.core.repository)
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.resources)

implementation(libs.androidx.room.paging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)

ksp(libs.androidx.room.compiler)

testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.room.testing)

androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.room.testing)
"kspAndroidHostTest"(libs.androidx.room.compiler)
"kspAndroidDeviceTest"(libs.androidx.room.compiler)
}
Loading