mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-19 04:09:37 -04:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user