Modularize database classes (#3192)

This commit is contained in:
Phil Oliver 2025-09-24 16:23:05 -04:00 committed by GitHub
parent 989a6bc820
commit 613714cdb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 384 additions and 431 deletions

View file

@ -1,124 +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 com.geeksville.mesh.database
import androidx.room.TypeConverter
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.android.Logging
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import kotlinx.serialization.json.Json
import org.meshtastic.core.model.DataPacket
@Suppress("TooManyFunctions")
class Converters : Logging {
@TypeConverter
fun dataFromString(value: String): DataPacket {
val json = Json { isLenient = true }
return json.decodeFromString(DataPacket.serializer(), value)
}
@TypeConverter
fun dataToString(value: DataPacket): String {
val json = Json { isLenient = true }
return json.encodeToString(DataPacket.serializer(), value)
}
@TypeConverter
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio = try {
MeshProtos.FromRadio.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToFromRadio TypeConverter error:", ex)
MeshProtos.FromRadio.getDefaultInstance()
}
@TypeConverter fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToUser(bytes: ByteArray): MeshProtos.User = try {
MeshProtos.User.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToUser TypeConverter error:", ex)
MeshProtos.User.getDefaultInstance()
}
@TypeConverter fun userToBytes(value: MeshProtos.User): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position = try {
MeshProtos.Position.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToPosition TypeConverter error:", ex)
MeshProtos.Position.getDefaultInstance()
}
@TypeConverter fun positionToBytes(value: MeshProtos.Position): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry = try {
TelemetryProtos.Telemetry.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToTelemetry TypeConverter error:", ex)
TelemetryProtos.Telemetry.newBuilder().build() // Return an empty Telemetry object
}
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount = try {
PaxcountProtos.Paxcount.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToPaxcounter TypeConverter error:", ex)
PaxcountProtos.Paxcount.getDefaultInstance()
}
@TypeConverter fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata = try {
MeshProtos.DeviceMetadata.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToMetadata TypeConverter error:", ex)
MeshProtos.DeviceMetadata.getDefaultInstance()
}
@TypeConverter fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? = value.toByteArray()
@TypeConverter
fun fromStringList(value: String?): List<String>? {
if (value == null) {
return null
}
return Json.decodeFromString<List<String>>(value)
}
@TypeConverter
fun toStringList(list: List<String>?): String? {
if (list == null) {
return null
}
return Json.encodeToString(list)
}
@TypeConverter
fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes)
@TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray()
}

View file

@ -1,70 +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 com.geeksville.mesh.database
import android.app.Application
import com.geeksville.mesh.database.dao.DeviceHardwareDao
import com.geeksville.mesh.database.dao.FirmwareReleaseDao
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.dao.QuickChatActionDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {
@Provides
@Singleton
fun provideDatabase(app: Application): MeshtasticDatabase =
MeshtasticDatabase.getDatabase(app)
@Provides
fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao {
return database.nodeInfoDao()
}
@Provides
fun providePacketDao(database: MeshtasticDatabase): PacketDao {
return database.packetDao()
}
@Provides
fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao {
return database.meshLogDao()
}
@Provides
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao {
return database.quickChatActionDao()
}
@Provides
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao {
return database.deviceHardwareDao()
}
@Provides
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao {
return database.firmwareReleaseDao()
}
}

View file

@ -22,14 +22,14 @@ import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.entity.MeshLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import javax.inject.Inject
@Suppress("TooManyFunctions")

View file

@ -1,105 +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 com.geeksville.mesh.database
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteTable
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.geeksville.mesh.database.dao.DeviceHardwareDao
import com.geeksville.mesh.database.dao.FirmwareReleaseDao
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.database.entity.ReactionEntity
@Database(
entities =
[
MyNodeEntity::class,
NodeEntity::class,
Packet::class,
ContactSettings::class,
MeshLog::class,
QuickChatAction::class,
ReactionEntity::class,
MetadataEntity::class,
DeviceHardwareEntity::class,
FirmwareReleaseEntity::class,
],
autoMigrations =
[
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12),
AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
AutoMigration(from = 13, to = 14),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17),
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19),
AutoMigration(from = 19, to = 20),
],
version = 20,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun nodeInfoDao(): NodeInfoDao
abstract fun packetDao(): PacketDao
abstract fun meshLogDao(): MeshLogDao
abstract fun quickChatActionDao(): QuickChatActionDao
abstract fun deviceHardwareDao(): DeviceHardwareDao
abstract fun firmwareReleaseDao(): FirmwareReleaseDao
companion object {
fun getDatabase(context: Context): MeshtasticDatabase =
Room.databaseBuilder(context.applicationContext, MeshtasticDatabase::class.java, "meshtastic_database")
.fallbackToDestructiveMigration(false)
.build()
}
}
@DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo"))
class AutoMigration12to13 : AutoMigrationSpec

View file

@ -21,12 +21,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.NodeSortOption
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@ -38,6 +32,12 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.NodeInfoDao
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.onlineTimeThreshold
import javax.inject.Inject

View file

@ -18,15 +18,15 @@
package com.geeksville.mesh.database
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.ReactionEntity
import com.geeksville.mesh.model.Node
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.PacketDao
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import javax.inject.Inject

View file

@ -18,35 +18,28 @@
package com.geeksville.mesh.database
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.QuickChatAction
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.entity.QuickChatAction
import javax.inject.Inject
class QuickChatActionRepository @Inject constructor(
class QuickChatActionRepository
@Inject
constructor(
private val quickChatDaoLazy: dagger.Lazy<QuickChatActionDao>,
private val dispatchers: CoroutineDispatchers,
) {
private val quickChatActionDao by lazy {
quickChatDaoLazy.get()
}
private val quickChatActionDao by lazy { quickChatDaoLazy.get() }
fun getAllActions() = quickChatActionDao.getAll().flowOn(dispatchers.io)
suspend fun upsert(action: QuickChatAction) = withContext(dispatchers.io) {
quickChatActionDao.upsert(action)
}
suspend fun upsert(action: QuickChatAction) = withContext(dispatchers.io) { quickChatActionDao.upsert(action) }
suspend fun deleteAll() = withContext(dispatchers.io) {
quickChatActionDao.deleteAll()
}
suspend fun deleteAll() = withContext(dispatchers.io) { quickChatActionDao.deleteAll() }
suspend fun delete(action: QuickChatAction) = withContext(dispatchers.io) {
quickChatActionDao.delete(action)
}
suspend fun delete(action: QuickChatAction) = withContext(dispatchers.io) { quickChatActionDao.delete(action) }
suspend fun setItemPosition(uuid: Long, newPos: Int) = withContext(dispatchers.io) {
quickChatActionDao.updateActionPosition(uuid, newPos)
}
}
suspend fun setItemPosition(uuid: Long, newPos: Int) =
withContext(dispatchers.io) { quickChatActionDao.updateActionPosition(uuid, newPos) }
}

View file

@ -1,36 +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 com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(deviceHardware: DeviceHardwareEntity)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity?
@Query("DELETE FROM device_hardware")
suspend fun deleteAll()
}

View file

@ -1,40 +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 com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()
@Query("SELECT * FROM firmware_release")
suspend fun getAllReleases(): List<FirmwareReleaseEntity>
@Query("SELECT * FROM firmware_release WHERE release_type = :releaseType")
suspend fun getReleasesByType(releaseType: FirmwareReleaseType): List<FirmwareReleaseEntity>
}

View file

@ -1,60 +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 com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.geeksville.mesh.database.entity.MeshLog
import kotlinx.coroutines.flow.Flow
@Dao
interface MeshLogDao {
@Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
fun getAllLogs(maxItem: Int): Flow<List<MeshLog>>
@Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>>
/**
* Retrieves [MeshLog]s matching 'from_num' (nodeNum) and 'port_num' (PortNum).
*
* @param portNum If 0, returns all MeshPackets. Otherwise, filters by 'port_num'.
*/
@Query(
"""
SELECT * FROM log
WHERE from_num = :fromNum AND (:portNum = 0 AND port_num != 0 OR port_num = :portNum)
ORDER BY received_date DESC LIMIT 0,:maxItem
"""
)
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow<List<MeshLog>>
@Insert
fun insert(log: MeshLog)
@Query("DELETE FROM log")
fun deleteAll()
@Query("DELETE FROM log WHERE uuid = :uuid")
fun deleteLog(uuid: String)
@Query("DELETE FROM log WHERE from_num = :fromNum AND port_num = :portNum")
fun deleteLogs(fromNum: Int, portNum: Int)
}

View file

@ -1,224 +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 com.geeksville.mesh.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 com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.NodeWithRelations
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
@Dao
interface NodeInfoDao {
/**
* Verifies a [NodeEntity] before an upsert operation. It handles populating the publicKey for lazy migration,
* checks for public key conflicts with new nodes, and manages updates to existing nodes, particularly in cases of
* public key mismatches to prevent potential impersonation or data corruption.
*
* @param incomingNode The node entity to be verified.
* @return A [NodeEntity] that is safe to upsert, or null if the upsert should be aborted (e.g., due to an
* impersonation attempt, though this logic is currently commented out).
*/
private fun getVerifiedNodeForUpsert(incomingNode: NodeEntity): NodeEntity {
// Populate the NodeEntity.publicKey field from the User.publicKey for consistency
// and to support lazy migration.
incomingNode.publicKey = incomingNode.user.publicKey
val existingNodeEntity = getNodeByNum(incomingNode.num)?.node
return if (existingNodeEntity == null) {
handleNewNodeUpsertValidation(incomingNode)
} else {
handleExistingNodeUpsertValidation(existingNodeEntity, incomingNode)
}
}
/** Validates a new node before it is inserted into the database. */
private fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity {
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
if (newNode.publicKey?.isEmpty == false) {
val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey)
if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) {
// This is a potential impersonation attempt.
return nodeWithSamePK
}
}
// If no conflicting public key is found, or if the impersonation check is not active,
// the new node is considered safe to add.
return newNode
}
private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity {
// A public key is considered matching if the incoming key equals the existing key,
// OR if the existing key is empty (allowing a new key to be set or an update to proceed).
val isPublicKeyMatchingOrExistingIsEmpty =
existingNode.user.publicKey == incomingNode.publicKey || existingNode.user.publicKey.isEmpty
return if (isPublicKeyMatchingOrExistingIsEmpty) {
// Keys match or existing key was empty: trust the incoming node data completely.
// This allows for legitimate updates to user info and other fields.
val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes
incomingNode.copy(notes = resolvedNotes)
} else {
existingNode.copy(
lastHeard = incomingNode.lastHeard,
snr = incomingNode.snr,
position = incomingNode.position,
// Preserve the existing user object, but update its internal public key to EMPTY
// to reflect the conflict state.
user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(),
publicKey = ByteString.EMPTY,
notes = existingNode.notes,
)
}
}
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow<MyNodeEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
fun clearMyNodeInfo()
@Query(
"""
SELECT * FROM nodes
ORDER BY CASE
WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0
ELSE 1
END,
last_heard DESC
""",
)
@Transaction
fun nodeDBbyNum(): Flow<
Map<
@MapColumn(columnName = "num")
Int,
NodeWithRelations,
>,
>
@Query(
"""
WITH OurNode AS (
SELECT latitude, longitude
FROM nodes
WHERE num = (SELECT myNodeNum FROM my_node LIMIT 1)
)
SELECT * FROM nodes
WHERE (:includeUnknown = 1 OR short_name IS NOT NULL)
AND (:filter = ''
OR (long_name LIKE '%' || :filter || '%'
OR short_name LIKE '%' || :filter || '%'
OR printf('!%08x', CASE WHEN num < 0 THEN num + 4294967296 ELSE num END) LIKE '%' || :filter || '%'
OR CAST(CASE WHEN num < 0 THEN num + 4294967296 ELSE num END AS TEXT) LIKE '%' || :filter || '%'))
AND (:lastHeardMin = -1 OR last_heard >= :lastHeardMin)
AND (:hopsAwayMax = -1 OR (hops_away <= :hopsAwayMax AND hops_away >= 0) OR num = (SELECT myNodeNum FROM my_node LIMIT 1))
ORDER BY CASE
WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0
ELSE 1
END,
CASE
WHEN :sort = 'last_heard' THEN last_heard * -1
WHEN :sort = 'alpha' THEN UPPER(long_name)
WHEN :sort = 'distance' THEN
CASE
WHEN latitude IS NULL OR longitude IS NULL OR
(latitude = 0.0 AND longitude = 0.0) THEN 999999999
ELSE
(latitude - (SELECT latitude FROM OurNode)) *
(latitude - (SELECT latitude FROM OurNode)) +
(longitude - (SELECT longitude FROM OurNode)) *
(longitude - (SELECT longitude FROM OurNode))
END
WHEN :sort = 'hops_away' THEN
CASE
WHEN hops_away = -1 THEN 999999999
ELSE hops_away
END
WHEN :sort = 'channel' THEN channel
WHEN :sort = 'via_mqtt' THEN via_mqtt
WHEN :sort = 'via_favorite' THEN is_favorite * -1
ELSE 0
END ASC,
last_heard DESC
""",
)
@Transaction
fun getNodes(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>>
@Query("DELETE FROM nodes")
fun clearNodeInfo()
@Query("DELETE FROM nodes WHERE num=:num")
fun deleteNode(num: Int)
@Query("DELETE FROM nodes WHERE num IN (:nodeNums)")
fun deleteNodes(nodeNums: List<Int>)
@Query("SELECT * FROM nodes WHERE last_heard < :lastHeard")
fun getNodesOlderThan(lastHeard: Int): List<NodeEntity>
@Query("SELECT * FROM nodes WHERE short_name IS NULL")
fun getUnknownNodes(): List<NodeEntity>
@Upsert fun upsert(meta: MetadataEntity)
@Query("DELETE FROM metadata WHERE num=:num")
fun deleteMetadata(num: Int)
@Query("SELECT * FROM nodes WHERE num=:num")
@Transaction
fun getNodeByNum(num: Int): NodeWithRelations?
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
@Upsert fun doUpsert(node: NodeEntity)
fun upsert(node: NodeEntity) {
val verifiedNode = getVerifiedNodeForUpsert(node)
doUpsert(verifiedNode)
}
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun putAll(nodes: List<NodeEntity>)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
fun setNodeNotes(num: Int, notes: String)
}

View file

@ -1,233 +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 com.geeksville.mesh.database.dao
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 com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.PacketEntity
import com.geeksville.mesh.database.entity.ReactionEntity
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
@Dao
interface PacketDao {
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = :portNum
ORDER BY received_time ASC
""",
)
fun getAllPackets(portNum: Int): Flow<List<Packet>>
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1
ORDER BY received_time DESC
""",
)
fun getContactKeys(): Flow<
Map<
@MapColumn(columnName = "contact_key")
String,
Packet,
>,
>
@Query(
"""
SELECT COUNT(*) FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact
""",
)
suspend fun getMessageCount(contact: String): Int
@Query(
"""
SELECT COUNT(*) FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact AND read = 0
""",
)
suspend fun getUnreadCount(contact: String): Int
@Query(
"""
UPDATE packet
SET read = 1
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact AND read = 0 AND received_time <= :timestamp
""",
)
suspend fun clearUnreadCount(contact: String, timestamp: Long)
@Upsert suspend fun insert(packet: Packet)
@Transaction
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact
ORDER BY received_time DESC
""",
)
fun getMessagesFrom(contact: String): Flow<List<PacketEntity>>
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND data = :data
""",
)
suspend fun findDataPacket(data: DataPacket): Packet?
@Query("DELETE FROM packet WHERE uuid in (:uuidList)")
suspend fun deletePackets(uuidList: List<Long>)
@Query(
"""
DELETE FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND contact_key IN (:contactList)
""",
)
suspend fun deleteContacts(contactList: List<String>)
@Query("DELETE FROM packet WHERE uuid=:uuid")
suspend fun delete(uuid: Long)
@Transaction
suspend fun delete(packet: Packet) {
delete(packet.uuid)
}
@Query("SELECT packet_id FROM packet WHERE uuid IN (:uuidList)")
suspend fun getPacketIdsFrom(uuidList: List<Long>): List<Int>
@Query("DELETE FROM reactions WHERE reply_id IN (:packetIds)")
suspend fun deleteReactions(packetIds: List<Int>)
@Transaction
suspend fun deleteMessages(uuidList: List<Long>) {
val packetIds = getPacketIdsFrom(uuidList)
if (packetIds.isNotEmpty()) {
deleteReactions(packetIds)
}
deletePackets(uuidList)
}
@Update suspend fun update(packet: Packet)
@Transaction
suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
val new = data.copy(status = m)
findDataPacket(data)?.let { update(it.copy(data = new)) }
}
@Transaction
suspend fun updateMessageId(data: DataPacket, id: Int) {
val new = data.copy(id = id)
findDataPacket(data)?.let { update(it.copy(data = new)) }
}
@Query(
"""
SELECT data FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
ORDER BY received_time ASC
""",
)
suspend fun getDataPackets(): List<DataPacket>
@Transaction
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND packet_id = :requestId
ORDER BY received_time DESC
""",
)
suspend fun getPacketById(requestId: Int): Packet?
@Transaction
@Query("SELECT * FROM packet WHERE packet_id = :packetId LIMIT 1")
suspend fun getPacketByPacketId(packetId: Int): PacketEntity?
@Transaction
suspend fun getQueuedPackets(): List<DataPacket>? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 8
ORDER BY received_time ASC
""",
)
suspend fun getAllWaypoints(): List<Packet>
@Transaction
suspend fun deleteWaypoint(id: Int) {
val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid }
deleteMessages(uuidList)
}
@Query("SELECT * FROM contact_settings")
fun getContactSettings(): Flow<
Map<
@MapColumn(columnName = "contact_key")
String,
ContactSettings,
>,
>
@Query("SELECT * FROM contact_settings WHERE contact_key = :contact")
suspend fun getContactSettings(contact: String): ContactSettings?
@Upsert suspend fun upsertContactSettings(contacts: List<ContactSettings>)
@Transaction
suspend fun setMuteUntil(contacts: List<String>, until: Long) {
val contactList =
contacts.map { contact ->
getContactSettings(contact)?.copy(muteUntil = until)
?: ContactSettings(contact_key = contact, muteUntil = until)
}
upsertContactSettings(contactList)
}
@Upsert suspend fun insert(reaction: ReactionEntity)
@Query("DELETE FROM packet")
suspend fun deleteAll()
}

View file

@ -1,53 +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 com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.geeksville.mesh.database.entity.QuickChatAction
import kotlinx.coroutines.flow.Flow
@Dao
interface QuickChatActionDao {
@Query("Select * from quick_chat order by position asc")
fun getAll(): Flow<List<QuickChatAction>>
@Upsert
fun upsert(action: QuickChatAction)
@Query("Delete from quick_chat")
fun deleteAll()
@Query("Delete from quick_chat where uuid=:uuid")
fun _delete(uuid: Long)
@Transaction
fun delete(action: QuickChatAction) {
_delete(action.uuid)
decrementPositionsAfter(action.position)
}
@Query("Update quick_chat set position=:position WHERE uuid=:uuid")
fun updateActionPosition(uuid: Long, position: Int)
@Query("Update quick_chat set position=position-1 where position>=:position")
fun decrementPositionsAfter(position: Int)
}

View file

@ -1,77 +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 com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.NetworkDeviceHardware
@Serializable
@Entity(tableName = "device_hardware")
data class DeviceHardwareEntity(
@ColumnInfo(name = "actively_supported") val activelySupported: Boolean,
val architecture: String,
@ColumnInfo(name = "display_name") val displayName: String,
@ColumnInfo(name = "has_ink_hud") val hasInkHud: Boolean? = null,
@ColumnInfo(name = "has_mui") val hasMui: Boolean? = null,
@PrimaryKey val hwModel: Int,
@ColumnInfo(name = "hw_model_slug") val hwModelSlug: String,
val images: List<String>?,
@ColumnInfo(name = "last_updated") val lastUpdated: Long = System.currentTimeMillis(),
@ColumnInfo(name = "partition_scheme") val partitionScheme: String? = null,
@ColumnInfo(name = "platformio_target") val platformioTarget: String,
@ColumnInfo(name = "requires_dfu") val requiresDfu: Boolean?,
@ColumnInfo(name = "support_level") val supportLevel: Int?,
val tags: List<String>?,
)
fun NetworkDeviceHardware.asEntity() = DeviceHardwareEntity(
activelySupported = activelySupported,
architecture = architecture,
displayName = displayName,
hasInkHud = hasInkHud,
hasMui = hasMui,
hwModel = hwModel,
hwModelSlug = hwModelSlug,
images = images,
lastUpdated = System.currentTimeMillis(),
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
supportLevel = supportLevel,
tags = tags,
)
fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
activelySupported = activelySupported,
architecture = architecture,
displayName = displayName,
hasInkHud = hasInkHud,
hasMui = hasMui,
hwModel = hwModel,
hwModelSlug = hwModelSlug,
images = images,
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
supportLevel = supportLevel,
tags = tags,
)

View file

@ -1,76 +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 com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.NetworkFirmwareRelease
@Serializable
@Entity(tableName = "firmware_release")
data class FirmwareReleaseEntity(
@PrimaryKey @ColumnInfo(name = "id") val id: String = "",
@ColumnInfo(name = "page_url") val pageUrl: String = "",
@ColumnInfo(name = "release_notes") val releaseNotes: String = "",
@ColumnInfo(name = "title") val title: String = "",
@ColumnInfo(name = "zip_url") val zipUrl: String = "",
@ColumnInfo(name = "last_updated") val lastUpdated: Long = System.currentTimeMillis(),
@ColumnInfo(name = "release_type") val releaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
)
fun NetworkFirmwareRelease.asEntity(releaseType: FirmwareReleaseType) = FirmwareReleaseEntity(
id = id,
pageUrl = pageUrl,
releaseNotes = releaseNotes,
title = title,
zipUrl = zipUrl,
lastUpdated = System.currentTimeMillis(),
releaseType = releaseType,
)
fun FirmwareReleaseEntity.asExternalModel() = FirmwareRelease(
id = id,
pageUrl = pageUrl,
releaseNotes = releaseNotes,
title = title,
zipUrl = zipUrl,
lastUpdated = lastUpdated,
releaseType = releaseType,
)
data class FirmwareRelease(
val id: String = "",
val pageUrl: String = "",
val releaseNotes: String = "",
val title: String = "",
val zipUrl: String = "",
val lastUpdated: Long = System.currentTimeMillis(),
val releaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
)
fun FirmwareReleaseEntity.asDeviceVersion(): DeviceVersion = DeviceVersion(id.substringBeforeLast(".").replace("v", ""))
fun FirmwareRelease.asDeviceVersion(): DeviceVersion = DeviceVersion(id.substringBeforeLast(".").replace("v", ""))
enum class FirmwareReleaseType {
STABLE,
ALPHA,
}

View file

@ -1,96 +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 com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.FromRadio
import com.geeksville.mesh.Portnums
import com.google.protobuf.TextFormat
import java.io.IOException
@Entity(
tableName = "log",
indices = [
Index(value = ["from_num"]),
Index(value = ["port_num"]),
],
)
data class MeshLog(
@PrimaryKey val uuid: String,
@ColumnInfo(name = "type") val message_type: String,
@ColumnInfo(name = "received_date") val received_date: Long,
@ColumnInfo(name = "message") val raw_message: String,
@ColumnInfo(name = "from_num", defaultValue = "0") val fromNum: Int = 0,
@ColumnInfo(name = "port_num", defaultValue = "0") val portNum: Int = 0,
@ColumnInfo(name = "from_radio", typeAffinity = ColumnInfo.BLOB, defaultValue = "x''")
val fromRadio: FromRadio = FromRadio.getDefaultInstance(),
) {
val meshPacket: MeshProtos.MeshPacket?
get() {
if (message_type == "Packet") {
val builder = MeshProtos.MeshPacket.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {
}
}
return null
}
val nodeInfo: MeshProtos.NodeInfo?
get() {
if (message_type == "NodeInfo") {
val builder = MeshProtos.NodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {
}
}
return null
}
val myNodeInfo: MeshProtos.MyNodeInfo?
get() {
if (message_type == "MyNodeInfo") {
val builder = MeshProtos.MyNodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {
}
}
return null
}
val position: MeshProtos.Position?
get() {
return meshPacket?.run {
if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) {
return MeshProtos.Position.parseFrom(decoded.payload)
}
return null
} ?: nodeInfo?.position
}
}

View file

@ -1,58 +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 com.geeksville.mesh.database.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.meshtastic.core.model.MyNodeInfo
@Entity(tableName = "my_node")
data class MyNodeEntity(
@PrimaryKey(autoGenerate = false) val myNodeNum: Int,
val model: String?,
val firmwareVersion: String?,
val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want
val shouldUpdate: Boolean, // this device has old firmware
val currentPacketId: Long,
val messageTimeoutMsec: Int,
val minAppVersion: Int,
val maxChannels: Int,
val hasWifi: Boolean,
val deviceId: String? = "unknown",
) {
/** A human readable description of the software/hardware version */
val firmwareString: String
get() = "$model $firmwareVersion"
fun toMyNodeInfo() = MyNodeInfo(
myNodeNum = myNodeNum,
hasGPS = false,
model = model,
firmwareVersion = firmwareVersion,
couldUpdate = couldUpdate,
shouldUpdate = shouldUpdate,
currentPacketId = currentPacketId,
messageTimeoutMsec = messageTimeoutMsec,
minAppVersion = minAppVersion,
maxChannels = maxChannels,
hasWifi = hasWifi,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = deviceId,
)
}

View file

@ -1,224 +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 com.geeksville.mesh.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 com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Node
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
data class NodeWithRelations(
@Embedded val node: NodeEntity,
@Relation(entity = MetadataEntity::class, parentColumn = "num", entityColumn = "num")
val metadata: MetadataEntity? = null,
) {
fun toModel() = with(node) {
Node(
num = num,
metadata = metadata?.proto,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
notes = notes,
)
}
fun toEntity() = with(node) {
NodeEntity(
num = num,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceTelemetry = deviceTelemetry,
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
environmentTelemetry = environmentTelemetry,
powerTelemetry = powerTelemetry,
paxcounter = paxcounter,
notes = notes,
)
}
}
@Entity(tableName = "metadata", indices = [Index(value = ["num"])])
data class MetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: MeshProtos.DeviceMetadata,
val timestamp: Long = System.currentTimeMillis(),
)
@Suppress("MagicNumber")
@Entity(tableName = "nodes")
data class NodeEntity(
@PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
@ColumnInfo(name = "long_name") var longName: String? = null,
@ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var snr: Float = Float.MAX_VALUE,
var rssi: Int = Int.MAX_VALUE,
@ColumnInfo(name = "last_heard") var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
@ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB)
var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
var channel: Int = 0,
@ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false,
@ColumnInfo(name = "hops_away") var hopsAway: Int = -1,
@ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false,
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
@ColumnInfo(name = "public_key") var publicKey: ByteString? = null,
@ColumnInfo(name = "notes", defaultValue = "") var notes: String = "",
) {
val deviceMetrics: TelemetryProtos.DeviceMetrics
get() = deviceTelemetry.deviceMetrics
val environmentMetrics: TelemetryProtos.EnvironmentMetrics
get() = environmentTelemetry.environmentMetrics
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) {
position = p.copy { time = if (p.time != 0) p.time else defaultTime }
latitude = degD(p.latitudeI)
longitude = degD(p.longitudeI)
}
/** true if the device was heard from recently */
val isOnline: Boolean
get() {
return lastHeard > onlineTimeThreshold()
}
companion object {
// Convert to a double representation of degrees
fun degD(i: Int) = i * 1e-7
fun degI(d: Double) = (d * 1e7).toInt()
val ERROR_BYTE_STRING: ByteString = ByteString.copyFrom(ByteArray(32) { 0 })
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
fun toModel() = Node(
num = num,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
publicKey = publicKey ?: user.publicKey,
notes = notes,
)
fun toNodeInfo() = NodeInfo(
num = num,
user =
MeshUser(
id = user.id,
longName = user.longName,
shortName = user.shortName,
hwModel = user.hwModel,
role = user.roleValue,
)
.takeIf { user.id.isNotEmpty() },
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude,
time = position.time,
satellitesInView = position.satsInView,
groundSpeed = position.groundSpeed,
groundTrack = position.groundTrack,
precisionBits = position.precisionBits,
)
.takeIf { it.isValid() },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage,
channelUtilization = deviceMetrics.channelUtilization,
airUtilTx = deviceMetrics.airUtilTx,
uptimeSeconds = deviceMetrics.uptimeSeconds,
),
channel = channel,
environmentMetrics =
EnvironmentMetrics.fromTelemetryProto(
environmentTelemetry.environmentMetrics,
environmentTelemetry.time,
),
hopsAway = hopsAway,
)
}

View file

@ -1,104 +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 com.geeksville.mesh.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 com.geeksville.mesh.MeshProtos.User
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getShortDateTime
data class PacketEntity(
@Embedded val packet: Packet,
@Relation(entity = ReactionEntity::class, parentColumn = "packet_id", entityColumn = "reply_id")
val reactions: List<ReactionEntity> = emptyList(),
) {
suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) {
val node = getNode(data.from)
Message(
uuid = uuid,
receivedTime = received_time,
node = node,
fromLocal = node.user.id == DataPacket.ID_LOCAL,
text = data.text.orEmpty(),
time = getShortDateTime(data.time),
snr = snr,
rssi = rssi,
hopsAway = hopsAway,
read = read,
status = data.status,
routingError = routingError,
packetId = packetId,
emojis = reactions.toReaction(getNode),
replyId = data.replyId,
viaMqtt = node.viaMqtt,
)
}
}
@Entity(
tableName = "packet",
indices = [Index(value = ["myNodeNum"]), Index(value = ["port_num"]), Index(value = ["contact_key"])],
)
data class Packet(
@PrimaryKey(autoGenerate = true) val uuid: Long,
@ColumnInfo(name = "myNodeNum", defaultValue = "0") val myNodeNum: Int,
@ColumnInfo(name = "port_num") val port_num: Int,
@ColumnInfo(name = "contact_key") val contact_key: String,
@ColumnInfo(name = "received_time") val received_time: Long,
@ColumnInfo(name = "read", defaultValue = "1") val read: Boolean,
@ColumnInfo(name = "data") val data: DataPacket,
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
@ColumnInfo(name = "reply_id", defaultValue = "0") val replyId: Int = 0,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
)
@Entity(tableName = "contact_settings")
data class ContactSettings(@PrimaryKey val contact_key: String, val muteUntil: Long = 0L) {
val isMuted
get() = System.currentTimeMillis() <= muteUntil
}
data class Reaction(val replyId: Int, val user: User, val emoji: String, val timestamp: Long)
@Entity(
tableName = "reactions",
primaryKeys = ["reply_id", "user_id", "emoji"],
indices = [Index(value = ["reply_id"])],
)
data class ReactionEntity(
@ColumnInfo(name = "reply_id") val replyId: Int,
@ColumnInfo(name = "user_id") val userId: String,
val emoji: String,
val timestamp: Long,
)
private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node) =
Reaction(replyId = replyId, user = getNode(userId).user, emoji = emoji, timestamp = timestamp)
private suspend fun List<ReactionEntity>.toReaction(getNode: suspend (userId: String?) -> Node) =
this.map { it.toReaction(getNode) }

View file

@ -1,36 +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 com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "quick_chat")
data class QuickChatAction(
@PrimaryKey(autoGenerate = true) val uuid: Long = 0L,
@ColumnInfo(name = "name") val name: String = "",
@ColumnInfo(name = "message") val message: String = "",
@ColumnInfo(name = "mode") val mode: Mode = Mode.Instant,
@ColumnInfo(name = "position") val position: Int
) {
enum class Mode {
Append,
Instant,
}
}

View file

@ -28,7 +28,6 @@ import com.geeksville.mesh.StoreAndForwardProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.ui.debug.FilterMode
import com.google.protobuf.InvalidProtocolBufferException
@ -45,6 +44,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.MeshLog
import java.text.DateFormat
import java.util.Date
import java.util.Locale

View file

@ -1,79 +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 com.geeksville.mesh.model
import androidx.annotation.StringRes
import com.geeksville.mesh.MeshProtos.Routing
import com.geeksville.mesh.database.entity.Reaction
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R
@Suppress("CyclomaticComplexMethod")
@StringRes
fun getStringResFrom(routingError: Int): Int = when (routingError) {
Routing.Error.NONE_VALUE -> R.string.routing_error_none
Routing.Error.NO_ROUTE_VALUE -> R.string.routing_error_no_route
Routing.Error.GOT_NAK_VALUE -> R.string.routing_error_got_nak
Routing.Error.TIMEOUT_VALUE -> R.string.routing_error_timeout
Routing.Error.NO_INTERFACE_VALUE -> R.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT_VALUE -> R.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL_VALUE -> R.string.routing_error_no_channel
Routing.Error.TOO_LARGE_VALUE -> R.string.routing_error_too_large
Routing.Error.NO_RESPONSE_VALUE -> R.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT_VALUE -> R.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST_VALUE -> R.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED_VALUE -> R.string.routing_error_not_authorized
Routing.Error.PKI_FAILED_VALUE -> R.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY_VALUE -> R.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY_VALUE -> R.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED_VALUE -> R.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED_VALUE -> R.string.routing_error_rate_limit_exceeded
else -> R.string.unrecognized
}
data class Message(
val uuid: Long,
val receivedTime: Long,
val node: Node,
val text: String,
val fromLocal: Boolean,
val time: String,
val read: Boolean,
val status: MessageStatus?,
val routingError: Int,
val packetId: Int,
val emojis: List<Reaction>,
val snr: Float,
val rssi: Int,
val hopsAway: Int,
val replyId: Int?,
val originalMessage: Message? = null,
val viaMqtt: Boolean = false,
) {
fun getStatusStringRes(): Pair<Int, Int> {
val title = if (routingError > 0) R.string.error else R.string.message_delivery_status
val text =
when (status) {
MessageStatus.RECEIVED -> R.string.delivery_confirmed
MessageStatus.QUEUED -> R.string.message_status_queued
MessageStatus.ENROUTE -> R.string.message_status_enroute
else -> getStringResFrom(routingError)
}
return title to text
}
}

View file

@ -36,8 +36,6 @@ import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.entity.FirmwareRelease
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
@ -58,6 +56,9 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.navigation.NodesRoutes

View file

@ -1,174 +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 com.geeksville.mesh.model
import android.graphics.Color
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos.DeviceMetrics
import com.geeksville.mesh.TelemetryProtos.EnvironmentMetrics
import com.geeksville.mesh.TelemetryProtos.PowerMetrics
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.util.toDistanceString
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.latLongToMeter
@Suppress("MagicNumber")
data class Node(
val num: Int,
val metadata: MeshProtos.DeviceMetadata? = null,
val user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
val position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
val snr: Float = Float.MAX_VALUE,
val rssi: Int = Int.MAX_VALUE,
val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
val deviceMetrics: DeviceMetrics = DeviceMetrics.getDefaultInstance(),
val channel: Int = 0,
val viaMqtt: Boolean = false,
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(),
val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(),
val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
val publicKey: ByteString? = null,
val notes: String = "",
) {
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
}
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
val mismatchKey
get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics.getDefaultInstance()
val batteryLevel
get() = deviceMetrics.batteryLevel
val voltage
get() = deviceMetrics.voltage
val batteryStr
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
val latitude
get() = position.latitudeI * 1e-7
val longitude
get() = position.longitudeI * 1e-7
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
val validPosition: MeshProtos.Position?
get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
fun distance(o: Node): Int? = when {
validPosition == null || o.validPosition == null -> null
else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt()
}
// @return formatted distance string to another node, using the given display units
fun distanceStr(o: Node, displayUnits: DisplayConfig.DisplayUnits): String? =
distance(o)?.toDistanceString(displayUnits)
// @return bearing to the other position in degrees
fun bearing(o: Node?): Int? = when {
validPosition == null || o?.validPosition == null -> null
else -> org.meshtastic.core.model.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
val temp =
if (temperature != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature))
} else {
"%.1f°C".format(temperature)
}
} else {
null
}
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
val soilTemperatureStr =
if (soilTemperature != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
} else {
"%.1f°C".format(soilTemperature)
}
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
"%d%%".format(soilMoisture)
} else {
null
}
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
val current = if (current != 0f) "%.1fmA".format(current) else null
val iaq = if (iaq != 0) "IAQ: $iaq" else null
return listOfNotNull(temp, humidity, soilTemperatureStr, soilMoisture, voltage, current, iaq).joinToString(" ")
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
fun getTelemetryString(isFahrenheit: Boolean = false): String =
listOfNotNull(paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit))
.joinToString(" ")
}
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
listOf(
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
)

View file

@ -1,31 +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 com.geeksville.mesh.model
import androidx.annotation.StringRes
import org.meshtastic.core.strings.R
enum class NodeSortOption(val sqlValue: String, @StringRes val stringRes: Int) {
LAST_HEARD("last_heard", R.string.node_sort_last_heard),
ALPHABETICAL("alpha", R.string.node_sort_alpha),
DISTANCE("distance", R.string.node_sort_distance),
HOPS_AWAY("hops_away", R.string.node_sort_hops_away),
CHANNEL("channel", R.string.node_sort_channel),
VIA_MQTT("via_mqtt", R.string.node_sort_via_mqtt),
VIA_FAVORITE("via_favorite", R.string.node_sort_via_favorite),
}

View file

@ -46,10 +46,6 @@ import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.database.entity.asDeviceVersion
import com.geeksville.mesh.repository.api.DeviceHardwareRepository
import com.geeksville.mesh.repository.api.FirmwareReleaseRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
@ -78,6 +74,13 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware

View file

@ -17,11 +17,11 @@
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.database.dao.DeviceHardwareDao
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
import com.geeksville.mesh.database.entity.asEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.model.NetworkDeviceHardware
import javax.inject.Inject

View file

@ -19,10 +19,10 @@ package com.geeksville.mesh.repository.api
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.database.entity.DeviceHardwareEntity
import com.geeksville.mesh.database.entity.asExternalModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import java.util.concurrent.TimeUnit

View file

@ -17,14 +17,14 @@
package com.geeksville.mesh.repository.api
import com.geeksville.mesh.database.dao.FirmwareReleaseDao
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseType
import com.geeksville.mesh.database.entity.asDeviceVersion
import com.geeksville.mesh.database.entity.asEntity
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.model.NetworkFirmwareRelease
import javax.inject.Inject

View file

@ -19,12 +19,12 @@ package com.geeksville.mesh.repository.api
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.warn
import com.geeksville.mesh.database.entity.FirmwareRelease
import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
import com.geeksville.mesh.database.entity.FirmwareReleaseType
import com.geeksville.mesh.database.entity.asExternalModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
import java.util.concurrent.TimeUnit
import javax.inject.Inject

View file

@ -30,11 +30,7 @@ import com.geeksville.mesh.MeshProtos.DeviceMetadata
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.service.ServiceAction
@ -45,6 +41,10 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.ChannelSetDataSource
import org.meshtastic.core.datastore.LocalConfigDataSource
import org.meshtastic.core.datastore.ModuleConfigDataSource

View file

@ -56,14 +56,8 @@ import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.copy
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.ReactionEntity
import com.geeksville.mesh.fromRadio
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.position
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
@ -90,6 +84,12 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser

View file

@ -38,8 +38,8 @@ import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R.raw
import com.geeksville.mesh.TelemetryProtos.LocalStats
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.strings.R

View file

@ -26,7 +26,6 @@ import com.geeksville.mesh.android.BuildUtils.info
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.fromRadio
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
@ -36,6 +35,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.toOneLineString

View file

@ -80,7 +80,6 @@ import com.geeksville.mesh.android.AddNavigationTracking
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.setAttributes
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.channelsGraph
import com.geeksville.mesh.navigation.connectionsGraph
@ -111,6 +110,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes

View file

@ -46,7 +46,6 @@ import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.isConfigRoute
import com.geeksville.mesh.navigation.isNodeDetailRoute
@ -55,6 +54,7 @@ import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.debug.DebugMenuActions
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.SettingsRoutes

View file

@ -31,8 +31,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.util.DistanceUnit
import com.geeksville.mesh.util.toDistanceString
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
import kotlin.math.pow
import kotlin.math.roundToInt

View file

@ -23,7 +23,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.model.Node
import org.meshtastic.core.database.model.Node
/** Simple [PreviewParameterProvider] that provides true and false values. */
class BooleanProvider : PreviewParameterProvider<Boolean> {

View file

@ -26,9 +26,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.common.theme.AppTheme
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
const val MAX_VALID_SNR = 100F

View file

@ -22,11 +22,11 @@ import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.deviceMetrics
import com.geeksville.mesh.environmentMetrics
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.paxcount
import com.geeksville.mesh.position
import com.geeksville.mesh.user
import com.google.protobuf.ByteString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime
import kotlin.random.Random

View file

@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.service.ConnectionState
@ -80,6 +79,7 @@ import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialo
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.delay
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.R

View file

@ -21,8 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@ -31,6 +29,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import javax.inject.Inject

View file

@ -43,12 +43,12 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.common.components.MaterialBatteryInfo
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
@Composable

View file

@ -21,8 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@ -31,6 +29,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.prefs.map.MapPrefs
@Suppress("TooManyFunctions")

View file

@ -95,9 +95,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannel
import com.geeksville.mesh.ui.common.components.SecurityIcon
@ -107,6 +104,9 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.strings.R
import java.nio.charset.StandardCharsets

View file

@ -47,8 +47,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.database.entity.Reaction
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.message.components.MessageItem
import com.geeksville.mesh.ui.message.components.ReactionDialog
@ -57,6 +55,8 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R

View file

@ -70,12 +70,12 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.common.components.dragContainer
import com.geeksville.mesh.ui.common.components.dragDropItemsIndexed
import com.geeksville.mesh.ui.common.components.rememberDragDropState
import com.geeksville.mesh.ui.common.theme.AppTheme
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.strings.R
@Composable

View file

@ -49,9 +49,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.database.entity.Reaction
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.common.components.MDText
import com.geeksville.mesh.ui.common.components.Rssi
import com.geeksville.mesh.ui.common.components.Snr
@ -60,6 +57,9 @@ import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.MessageItemColors
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.R

View file

@ -52,44 +52,25 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.database.entity.Reaction
import com.geeksville.mesh.ui.common.components.BottomSheetDialog
import com.geeksville.mesh.ui.common.theme.AppTheme
import org.meshtastic.core.database.entity.Reaction
@Composable
private fun ReactionItem(
emoji: String,
emojiCount: Int = 1,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
) {
private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) {
BadgedBox(
badge = {
if (emojiCount > 1) {
Badge {
Text(
fontWeight = FontWeight.Bold,
text = emojiCount.toString()
)
}
Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) }
}
}
},
) {
Surface(
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape,
) {
Text(
text = emoji,
modifier = Modifier
.padding(4.dp)
.clip(CircleShape),
)
Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape))
}
}
}
@ -102,10 +83,7 @@ fun ReactionRow(
onSendReaction: (String) -> Unit = {},
onShowReactions: () -> Unit = {},
) {
val emojiList =
reduceEmojis(
reactions.reversed().map { it.emoji }
).entries
val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries
AnimatedVisibility(emojiList.isNotEmpty()) {
LazyRow(
@ -113,16 +91,12 @@ fun ReactionRow(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
items(
emojiList.size
) { index ->
items(emojiList.size) { index ->
val entry = emojiList.elementAt(index)
ReactionItem(
emoji = entry.key,
emojiCount = entry.value,
onClick = {
onSendReaction(entry.key)
},
onClick = { onSendReaction(entry.key) },
onLongClick = onShowReactions,
)
}
@ -133,68 +107,47 @@ fun ReactionRow(
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
@Composable
fun ReactionDialog(
reactions: List<Reaction>,
onDismiss: () -> Unit = {}
) = BottomSheetDialog(
onDismiss = onDismiss,
modifier = Modifier.fillMaxHeight(fraction = .3f),
) {
val groupedEmojis = reactions.groupBy { it.emoji }
var selectedEmoji by remember { mutableStateOf<String?>(null) }
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
fun ReactionDialog(reactions: List<Reaction>, onDismiss: () -> Unit = {}) =
BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) {
val groupedEmojis = reactions.groupBy { it.emoji }
var selectedEmoji by remember { mutableStateOf<String?>(null) }
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(groupedEmojis.entries.toList()) { (emoji, reactions) ->
Text(
text = "$emoji${reactions.size}",
modifier = Modifier
.clip(CircleShape)
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
.padding(8.dp)
.clickable {
selectedEmoji = if (selectedEmoji == emoji) null else emoji
},
style = MaterialTheme.typography.bodyMedium
)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(filteredReactions) { reaction ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
items(groupedEmojis.entries.toList()) { (emoji, reactions) ->
Text(
text = reaction.user.longName,
style = MaterialTheme.typography.titleMedium
)
Text(
text = reaction.emoji,
style = MaterialTheme.typography.titleLarge
text = "$emoji${reactions.size}",
modifier =
Modifier.clip(CircleShape)
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
.padding(8.dp)
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },
style = MaterialTheme.typography.bodyMedium,
)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(filteredReactions) { reaction ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = reaction.user.longName, style = MaterialTheme.typography.titleMedium)
Text(text = reaction.emoji, style = MaterialTheme.typography.titleLarge)
}
}
}
}
}
@PreviewLightDark
@Composable
fun ReactionItemPreview() {
AppTheme {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background)
) {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
ReactionItem(emoji = "\uD83D\uDE42")
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
ReactionButton()
@ -207,20 +160,21 @@ fun ReactionItemPreview() {
fun ReactionRowPreview() {
AppTheme {
ReactionRow(
reactions = listOf(
reactions =
listOf(
Reaction(
replyId = 1,
user = MeshProtos.User.getDefaultInstance(),
emoji = "\uD83D\uDE42",
timestamp = 1L
timestamp = 1L,
),
Reaction(
replyId = 1,
user = MeshProtos.User.getDefaultInstance(),
emoji = "\uD83D\uDE42",
timestamp = 1L
timestamp = 1L,
),
)
),
)
}
}

View file

@ -55,11 +55,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.common.components.OptionLabel
import com.geeksville.mesh.ui.common.components.SlidingSelector
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import java.text.DateFormat

View file

@ -65,8 +65,8 @@ import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.util.metersIn
import com.geeksville.mesh.util.toString
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.R
import java.text.DateFormat
import kotlin.time.Duration.Companion.days

View file

@ -123,13 +123,9 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.database.entity.FirmwareRelease
import com.geeksville.mesh.database.entity.asDeviceVersion
import com.geeksville.mesh.model.MetricsState
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.common.components.MainAppBar
import com.geeksville.mesh.ui.common.components.TitledCard
@ -147,10 +143,11 @@ import com.geeksville.mesh.ui.settings.components.SettingsItemDetail
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.geeksville.mesh.util.thenIf
import com.geeksville.mesh.util.toDistanceString
import com.geeksville.mesh.util.toSmallDistanceString
import com.geeksville.mesh.util.toSpeedString
import com.mikepenz.markdown.m3.Markdown
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.DeviceVersion
@ -158,6 +155,9 @@ import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.model.util.toSmallDistanceString
import org.meshtastic.core.model.util.toSpeedString
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes

View file

@ -46,7 +46,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.ui.common.components.MainAppBar
@ -57,6 +56,7 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.sharing.AddContactFAB
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R

View file

@ -25,40 +25,21 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.util.metersIn
import com.geeksville.mesh.util.toString
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
@Composable
fun ElevationInfo(
modifier: Modifier = Modifier,
altitude: Int,
system: DisplayUnits,
suffix: String
) {
fun ElevationInfo(modifier: Modifier = Modifier, altitude: Int, system: DisplayUnits, suffix: String) {
val annotatedString = buildAnnotatedString {
append(altitude.metersIn(system).toString(system))
MaterialTheme.typography.labelSmall.toSpanStyle().let { style ->
withStyle(style) {
append(" $suffix")
}
}
MaterialTheme.typography.labelSmall.toSpanStyle().let { style -> withStyle(style) { append(" $suffix") } }
}
Text(
modifier = modifier,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
text = annotatedString,
)
Text(modifier = modifier, fontSize = MaterialTheme.typography.labelLarge.fontSize, text = annotatedString)
}
@Composable
@Preview
fun ElevationInfoPreview() {
MaterialTheme {
ElevationInfo(
altitude = 100,
system = DisplayUnits.METRIC,
suffix = "ASL"
)
}
MaterialTheme { ElevationInfo(altitude = 100, system = DisplayUnits.METRIC, suffix = "ASL") }
}

View file

@ -44,7 +44,7 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.model.Node
import org.meshtastic.core.database.model.Node
@Composable
fun NodeChip(

View file

@ -56,9 +56,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.NodeSortOption
import com.geeksville.mesh.ui.common.preview.LargeFontPreview
import com.geeksville.mesh.ui.common.theme.AppTheme
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.strings.R
@Suppress("LongParameterList")

View file

@ -51,13 +51,13 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.ui.common.components.BatteryInfo
import com.geeksville.mesh.ui.common.components.SignalInfo
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.util.toDistanceString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
@Suppress("LongMethod", "CyclomaticComplexMethod")

View file

@ -38,9 +38,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.strings.R
@Suppress("LongMethod")

View file

@ -28,8 +28,6 @@ import com.geeksville.mesh.Portnums
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -45,6 +43,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter

View file

@ -46,8 +46,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.ui.node.components.NodeChip
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.strings.R
/**

View file

@ -20,12 +20,12 @@ package com.geeksville.mesh.ui.settings.radio
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.database.entity.NodeEntity
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds

View file

@ -45,11 +45,8 @@ import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.isAnalyticsAvailable
import com.geeksville.mesh.config
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.getChannelList
import com.geeksville.mesh.model.getStringResFrom
import com.geeksville.mesh.model.toChannelSet
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.navigation.ConfigRoute
@ -73,6 +70,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.model.Position
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs

View file

@ -43,8 +43,8 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.ui.common.components.EditTextPreference
import com.geeksville.mesh.ui.common.components.SwitchPreference
import com.geeksville.mesh.ui.common.components.precisionBitsToMeters
import com.geeksville.mesh.util.DistanceUnit
import com.geeksville.mesh.util.toDistanceString
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.R
import kotlin.math.roundToInt

View file

@ -30,12 +30,12 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.isUnmessageableRole
import com.geeksville.mesh.ui.common.components.EditTextPreference
import com.geeksville.mesh.ui.common.components.PreferenceCategory
import com.geeksville.mesh.ui.common.components.RegularPreference
import com.geeksville.mesh.ui.common.components.SwitchPreference
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R

View file

@ -54,7 +54,6 @@ import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.android.BuildUtils.debug
import com.geeksville.mesh.android.BuildUtils.errormsg
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.common.components.CopyIconButton
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
@ -69,6 +68,7 @@ import com.google.zxing.WriterException
import com.journeyapps.barcodescanner.BarcodeEncoder
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.R
import timber.log.Timber

View file

@ -1,108 +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 com.geeksville.mesh.util
import android.icu.util.LocaleData
import android.icu.util.ULocale
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import java.util.Locale
enum class DistanceUnit(
val symbol: String,
val multiplier: Float,
val system: Int
) {
METER("m", multiplier = 1F, DisplayUnits.METRIC_VALUE),
KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC_VALUE),
FOOT("ft", multiplier = 3.28084F, DisplayUnits.IMPERIAL_VALUE),
MILE("mi", multiplier = 0.000621371F, DisplayUnits.IMPERIAL_VALUE),
;
companion object {
fun getFromLocale(locale: Locale = Locale.getDefault()): DisplayUnits {
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) {
LocaleData.MeasurementSystem.SI -> DisplayUnits.METRIC
else -> DisplayUnits.IMPERIAL
}
} else {
when (locale.country.uppercase(locale)) {
"US", "LR", "MM", "GB" -> DisplayUnits.IMPERIAL
else -> DisplayUnits.METRIC
}
}
}
}
}
fun Int.metersIn(unit: DistanceUnit): Float {
return this * unit.multiplier
}
fun Int.metersIn(system: DisplayUnits): Float {
val unit = when (system.number) {
DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT
else -> DistanceUnit.METER
}
return this.metersIn(unit)
}
fun Float.toString(unit: DistanceUnit): String {
return if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) {
"%.0f %s"
} else {
"%.1f %s"
}.format(this, unit.symbol)
}
fun Float.toString(system: DisplayUnits): String {
val unit = when (system.number) {
DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT
else -> DistanceUnit.METER
}
return this.toString(unit)
}
private const val KILOMETER_THRESHOLD = 1000
private const val MILE_THRESHOLD = 1609
fun Int.toDistanceString(system: DisplayUnits): String {
val unit = if (system.number == DisplayUnits.METRIC_VALUE) {
if (this < KILOMETER_THRESHOLD) DistanceUnit.METER else DistanceUnit.KILOMETER
} else {
if (this < MILE_THRESHOLD) DistanceUnit.FOOT else DistanceUnit.MILE
}
val valueInUnit = this * unit.multiplier
return valueInUnit.toString(unit)
}
@Suppress("MagicNumber")
fun Float.toSpeedString(system: DisplayUnits): String =
if (system == DisplayUnits.METRIC) {
"%.0f km/h".format(this * 3.6)
} else {
"%.0f mph".format(this * 2.23694f)
}
@Suppress("MagicNumber")
fun Float.toSmallDistanceString(system: DisplayUnits): String {
return if (system == DisplayUnits.IMPERIAL) {
"%.2f in".format(this / 25.4f)
} else {
"%.0f mm".format(this)
}
}