Feat/node notes (#3014)

This commit is contained in:
DaneEvans 2025-09-09 07:37:56 +10:00 committed by GitHub
parent 363764c5ce
commit f2680d37ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 889 additions and 107 deletions

View file

@ -1,106 +1,105 @@
/*
* 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),
],
version = 19,
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 {
return Room.databaseBuilder(
context.applicationContext,
MeshtasticDatabase::class.java,
"meshtastic_database"
)
.fallbackToDestructiveMigration(false)
.build()
}
}
}
@DeleteTable.Entries(
DeleteTable(tableName = "NodeInfo"),
DeleteTable(tableName = "MyNodeInfo")
)
class AutoMigration12to13 : AutoMigrationSpec
/*
* 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

@ -157,4 +157,7 @@ constructor(
val totalNodeCount: Flow<Int> =
nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(dispatchers.io).conflate()
suspend fun setNodeNotes(num: Int, notes: String) =
withContext(dispatchers.io) { nodeInfoDao.setNodeNotes(num, notes) }
}

View file

@ -83,7 +83,8 @@ interface NodeInfoDao {
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.
incomingNode
val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes
incomingNode.copy(notes = resolvedNotes)
} else {
existingNode.copy(
lastHeard = incomingNode.lastHeard,
@ -93,6 +94,7 @@ interface NodeInfoDao {
// to reflect the conflict state.
user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(),
publicKey = ByteString.EMPTY,
notes = existingNode.notes,
)
}
}
@ -216,4 +218,7 @@ interface NodeInfoDao {
@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

@ -60,6 +60,7 @@ data class NodeWithRelations(
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
notes = notes,
)
}
@ -80,6 +81,7 @@ data class NodeWithRelations(
environmentTelemetry = environmentTelemetry,
powerTelemetry = powerTelemetry,
paxcounter = paxcounter,
notes = notes,
)
}
}
@ -119,6 +121,7 @@ data class NodeEntity(
@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
@ -172,6 +175,7 @@ data class NodeEntity(
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
publicKey = publicKey ?: user.publicKey,
notes = notes,
)
fun toNodeInfo() = NodeInfo(

View file

@ -52,6 +52,7 @@ data class Node(
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'

View file

@ -762,6 +762,16 @@ constructor(
}
}
fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
try {
nodeDB.setNodeNotes(nodeNum, notes)
} catch (ex: java.io.IOException) {
errormsg("Set node notes IO error: ${ex.message}")
} catch (ex: java.sql.SQLException) {
errormsg("Set node notes SQL error: ${ex.message}")
}
}
// managed mode disables all access to configuration
val isManaged: Boolean
get() = config.device.isManaged || config.security.isManaged

View file

@ -91,6 +91,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -331,9 +332,35 @@ private fun NodeDetailContent(
},
modifier = modifier,
availableLogs = availableLogs,
onSaveNotes = { num, notes -> uiViewModel.setNodeNotes(num, notes) },
)
}
@Composable
private fun notesSection(node: Node, onSaveNotes: (Int, String) -> Unit) {
if (node.isFavorite) {
TitledCard(title = stringResource(R.string.notes)) {
val originalNotes = node.notes
var notes by remember(node.notes) { mutableStateOf(node.notes) }
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(id = R.string.add_a_note)) },
)
Spacer(modifier = Modifier.height(8.dp))
val edited = notes.trim() != originalNotes.trim()
if (edited) {
NodeActionButton(
title = stringResource(id = R.string.save),
enabled = true,
onClick = { onSaveNotes(node.num, notes.trim()) },
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailList(
@ -344,6 +371,7 @@ private fun NodeDetailList(
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
availableLogs: Set<LogsType>,
onSaveNotes: (Int, String) -> Unit,
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf<FirmwareRelease?>(null) }
@ -369,6 +397,7 @@ private fun NodeDetailList(
TitledCard(title = stringResource(R.string.details)) {
NodeDetailsContent(node, ourNode, metricsState.displayUnits)
}
notesSection(node = node, onSaveNotes = onSaveNotes)
DeviceActions(
isLocal = metricsState.isLocal,
@ -1067,6 +1096,7 @@ private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::c
metricsState = MetricsState.Empty,
availableLogs = emptySet(),
onAction = {},
onSaveNotes = { _, _ -> },
)
}
}