fix: resolve release/2.8.0 branch-review findings (car hosts, AI node IDs, discovery abort, AQ zeros) (#5813)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-16 15:31:58 -05:00
parent bfe3440a11
commit 8874352ba4
10 changed files with 289 additions and 45 deletions

View File

@@ -0,0 +1,141 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.PortNum
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Verifies FTS5 full-text message search (#5373) and the historical-message backfill.
*
* [backfillMessageTexts_makesHistoricalMessagesSearchable] is the regression guard for the original `json_extract(data,
* '$.text')` backfill, which silently matched nothing: `DataPacket.text` is a computed property and is never serialized
* into the stored JSON, so historical messages stayed permanently unsearchable.
*/
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
class PacketFtsSearchTest {
private lateinit var database: MeshtasticDatabase
private lateinit var packetDao: PacketDao
private lateinit var nodeInfoDao: NodeInfoDao
private val myNodeNum = 42424242
private val myNodeInfo =
MyNodeEntity(
myNodeNum = myNodeNum,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5 * 60 * 1000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
@Before
fun createDb(): Unit = runTest {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.setDriver(BundledSQLiteDriver())
.build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao = database.packetDao()
}
@After
fun closeDb() {
database.close()
}
@Test
fun searchMessages_matchesIndexedText_ignoresNonMatches() = runTest {
insertTextPacket(contactKey = CONTACT, text = "the quick brown fox", messageText = "the quick brown fox")
assertEquals(1, packetDao.searchMessages("brown").size, "a term in the indexed message should match")
assertTrue(packetDao.searchMessages("zebra").isEmpty(), "an absent term should not match")
}
@Test
fun searchMessagesInConversation_scopesToContact() = runTest {
insertTextPacket(contactKey = CONTACT, text = "shared keyword here", messageText = "shared keyword here")
insertTextPacket(contactKey = OTHER_CONTACT, text = "shared keyword here", messageText = "shared keyword here")
assertEquals(2, packetDao.searchMessages("keyword").size, "both conversations match the global search")
assertEquals(1, packetDao.searchMessagesInConversation("keyword", CONTACT).size, "scoped to one contact")
}
@Test
fun backfillMessageTexts_makesHistoricalMessagesSearchable() = runTest {
// A pre-v39 packet: the payload carries the text, but message_text was never populated, so it is unindexed.
insertTextPacket(contactKey = CONTACT, text = "historical needle", messageText = "")
assertTrue(packetDao.searchMessages("needle").isEmpty(), "the historical message is unindexed before backfill")
assertEquals(1, packetDao.countPacketsNeedingBackfill(), "the empty-message_text packet needs backfill")
val updated = packetDao.backfillMessageTexts()
packetDao.rebuildFtsIndex()
assertEquals(1, updated, "the historical text packet should be backfilled")
assertEquals(1, packetDao.searchMessages("needle").size, "the backfilled message should now be searchable")
assertEquals(0, packetDao.countPacketsNeedingBackfill(), "nothing left to backfill")
}
private suspend fun insertTextPacket(contactKey: String, text: String, messageText: String) {
packetDao.insert(
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis,
read = false,
data = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 0, text = text),
messageText = messageText,
),
)
}
companion object {
private const val CONTACT = "0!aaaa1111"
private const val OTHER_CONTACT = "0!bbbb2222"
}
}

View File

@@ -310,9 +310,10 @@ open class DatabaseManager(
}
/**
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema. Uses a single SQL
* UPDATE with json_extract to avoid loading all packets into memory, then rebuilds the FTS index so search covers
* historical messages.
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema, then rebuilds the
* FTS index so search covers historical messages. The text is decoded in Kotlin from each packet's payload (see
* [PacketDao.backfillMessageTexts]); it cannot be read in SQL because the message body is stored as serialized
* `bytes`, not a `text` JSON field.
*/
private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) {
val needsBackfill = db.packetDao().countPacketsNeedingBackfill() > 0

View File

@@ -568,19 +568,31 @@ interface PacketDao {
@Query("UPDATE packet SET message_text = :text WHERE uuid = :uuid")
suspend fun updateMessageText(uuid: Long, text: String)
@Query(
"SELECT COUNT(*) FROM packet " +
"WHERE port_num = 1 AND (message_text IS NULL OR message_text = '') " +
"AND json_extract(data, '\$.text') IS NOT NULL",
)
@Query("SELECT COUNT(*) FROM packet WHERE port_num = 1 AND (message_text IS NULL OR message_text = '')")
suspend fun countPacketsNeedingBackfill(): Int
@Query(
"UPDATE packet SET message_text = json_extract(data, '\$.text') " +
"WHERE port_num = 1 AND (message_text IS NULL OR message_text = '') " +
"AND json_extract(data, '\$.text') IS NOT NULL",
)
suspend fun backfillMessageTexts(): Int
@Query("SELECT * FROM packet WHERE port_num = 1 AND (message_text IS NULL OR message_text = '')")
suspend fun getPacketsNeedingBackfill(): List<Packet>
/**
* Populates [Packet.messageText] for historical text packets that predate the FTS5 schema (v39) so they become
* searchable. The text is decoded in Kotlin from each packet's [DataPacket.text]; it cannot be read with a SQL
* `json_extract(data, '$.text')` because [DataPacket.text] is a computed property that is never serialized into the
* stored JSON (the payload is persisted as `bytes`). Returns the number of rows updated; the caller rebuilds the
* FTS index via [rebuildFtsIndex] when this is greater than zero.
*/
@Transaction
suspend fun backfillMessageTexts(): Int {
var updated = 0
getPacketsNeedingBackfill().forEach { packet ->
val text = packet.data.text
if (!text.isNullOrEmpty()) {
updateMessageText(packet.uuid, text)
updated++
}
}
return updated
}
@Query("INSERT INTO packet_fts(packet_fts) VALUES('rebuild')")
suspend fun rebuildFtsIndex()