feat: add FTS5 full-text message search

Leverage Room 3.0.0-alpha04's FTS5 support to enable searching
message history. Uses external content table backed by Packet entity.

Schema changes (migration 38→39):
- Add message_text column to Packet entity
- Create PacketFts virtual table with FTS5 index on message_text
- Content triggers auto-sync FTS index on INSERT/UPDATE/DELETE

Search stack:
- PacketDao: searchMessages/searchMessagesInConversation queries
- DatabaseManager: backfillSearchIndexIfNeeded on DB switch
- PacketRepository: searchMessages with FTS query sanitization
- MessageViewModel: debounced search (300ms, min 2 chars)
- MessageSearchBar: M3 TopAppBar replacement pattern

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 13:52:50 -05:00
parent 78b9a942dc
commit 0e59ed7a13
13 changed files with 1380 additions and 2 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -149,6 +149,9 @@ open class DatabaseManager(
// One-time cleanup: remove legacy DB if present and not active
managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) }
// Backfill FTS search index for any text messages missing messageText
managerScope.launch(dispatchers.io) { backfillSearchIndexIfNeeded(db) }
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
@@ -290,6 +293,26 @@ open class DatabaseManager(
datastore.edit { it[legacyCleanedKey] = true }
}
/**
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema. Iterates packets
* with empty messageText, extracts text from [DataPacket], and updates. Finally rebuilds the FTS index so search
* covers historical messages.
*/
private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) {
val packetDao = db.packetDao()
val packets = packetDao.getAllUserPacketsForMigration()
val toUpdate = packets.filter { it.messageText.isEmpty() && it.data.text != null }
if (toUpdate.isEmpty()) return
Logger.i { "Backfilling FTS search index for ${toUpdate.size} messages" }
for (packet in toUpdate) {
val text = packet.data.text ?: continue
packetDao.updateMessageText(packet.uuid, text)
}
packetDao.rebuildFtsIndex()
Logger.i { "FTS search index backfill complete" }
}
/** Closes all open databases and cancels background work. */
fun close() {
managerScope.cancel()

View File

@@ -39,6 +39,7 @@ 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.entity.Packet
import org.meshtastic.core.database.entity.PacketFts
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@@ -49,6 +50,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
MyNodeEntity::class,
NodeEntity::class,
Packet::class,
PacketFts::class,
ContactSettings::class,
MeshLog::class,
QuickChatAction::class,
@@ -95,8 +97,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
AutoMigration(from = 38, to = 39),
],
version = 38,
version = 39,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -120,7 +123,9 @@ abstract class MeshtasticDatabase : RoomDatabase() {
companion object {
/** Configures a [RoomDatabase.Builder] with standard settings for this project. */
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
this.fallbackToDestructiveMigration(dropAllTables = false).setQueryCoroutineContext(ioDispatcher)
this.fallbackToDestructiveMigration(dropAllTables = false)
.setMultipleConnectionPool(maxNumOfReaders = 4, maxNumOfWriters = 1)
.setQueryCoroutineContext(ioDispatcher)
}
}

View File

@@ -547,4 +547,29 @@ interface PacketDao {
"UPDATE packet SET filtered = :filtered WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) AND data LIKE :senderIdPattern",
)
suspend fun updateFilteredBySender(senderIdPattern: String, filtered: Boolean)
// region ── FTS5 Search ──
@Query(
"SELECT packet.* FROM packet JOIN packet_fts ON packet.rowid = packet_fts.rowid " +
"WHERE packet_fts MATCH :query AND packet.myNodeNum = (SELECT myNodeNum FROM my_node) " +
"ORDER BY packet.received_time DESC LIMIT 100",
)
suspend fun searchMessages(query: String): List<Packet>
@Query(
"SELECT packet.* FROM packet JOIN packet_fts ON packet.rowid = packet_fts.rowid " +
"WHERE packet_fts MATCH :query AND packet.contact_key = :contactKey " +
"AND packet.myNodeNum = (SELECT myNodeNum FROM my_node) " +
"ORDER BY packet.received_time DESC LIMIT 100",
)
suspend fun searchMessagesInConversation(query: String, contactKey: String): List<Packet>
@Query("UPDATE packet SET message_text = :text WHERE uuid = :uuid")
suspend fun updateMessageText(uuid: Long, text: String)
@Query("INSERT INTO packet_fts(packet_fts) VALUES('rebuild')")
suspend fun rebuildFtsIndex()
// endregion
}

View File

@@ -94,6 +94,7 @@ data class Packet(
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
@ColumnInfo(name = "message_text", defaultValue = "") val messageText: String = "",
) {
companion object {
const val RELAY_NODE_SUFFIX_MASK = 0xFF

View File

@@ -0,0 +1,29 @@
/*
* 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.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.Fts5
/**
* FTS5 virtual table that mirrors [Packet.messageText] for full-text search. Room auto-generates INSERT/UPDATE/DELETE
* triggers to keep this table in sync with the content entity ([Packet]).
*/
@Fts5(contentEntity = Packet::class)
@Entity(tableName = "packet_fts")
data class PacketFts(@ColumnInfo(name = "message_text") val messageText: String)