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

@@ -1018,6 +1018,8 @@ scanning_network
screen_on_for
scroll_to_bottom
search_emoji
search_messages
search_result_count
secondary
secondary_channel_position_feature
secondary_no_telemetry

View File

@@ -22,6 +22,7 @@ import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
@@ -139,6 +140,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = filtered,
messageText = packet.text.orEmpty(),
)
insertRoomPacket(packetToSave)
}
@@ -278,6 +280,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = filtered,
messageText = packet.text.orEmpty(),
)
insertRoomPacket(packetToSave)
}
@@ -510,6 +513,54 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
sfpp_hash = sfppHash,
)
override fun searchMessages(query: String, contactKey: String?, getNode: (String?) -> Node): Flow<List<Message>> {
val sanitized = sanitizeFtsQuery(query)
if (sanitized.isBlank()) return flowOf(emptyList())
return dbManager.currentDb.flatMapLatest { db ->
kotlinx.coroutines.flow.flow {
val dao = db.packetDao()
val packets =
if (contactKey != null) {
dao.searchMessagesInConversation(sanitized, contactKey)
} else {
dao.searchMessages(sanitized)
}
emit(
packets.map { packet ->
val node = getNode(packet.data.from)
val isFromLocal =
node.user.id == DataPacket.ID_LOCAL ||
(packet.myNodeNum != 0 && node.num == packet.myNodeNum)
Message(
uuid = packet.uuid,
receivedTime = packet.received_time,
node = node,
text = packet.data.text.orEmpty(),
fromLocal = isFromLocal,
time = org.meshtastic.core.model.util.getShortDateTime(packet.data.time),
snr = packet.snr,
rssi = packet.rssi,
hopsAway = packet.hopsAway,
read = packet.read,
status = packet.data.status,
routingError = packet.routingError,
packetId = packet.packetId,
emojis = emptyList(),
replyId = packet.data.replyId,
)
},
)
}
}
}
/**
* Sanitizes a user query for FTS5 by wrapping each token in double quotes. This escapes FTS5 special characters (*,
* -, NEAR, etc.) while still allowing multi-word searches as implicit AND queries.
*/
private fun sanitizeFtsQuery(query: String): String =
query.split("\\s+".toRegex()).filter { it.isNotBlank() }.joinToString(" ") { "\"${it.replace("\"", "")}\"" }
companion object {
private const val CONTACTS_PAGE_SIZE = 30
private const val MESSAGES_PAGE_SIZE = 50

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)

View File

@@ -216,4 +216,14 @@ interface PacketRepository {
/** Updates the SFPP status of packets matching the given commit hash. */
suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long)
/**
* Searches message history using full-text search.
*
* @param query The search text (will be sanitized for FTS5).
* @param contactKey Optional contact key to scope search to a single conversation.
* @param getNode Function to resolve node info by userId.
* @return Flow emitting matching messages.
*/
fun searchMessages(query: String, contactKey: String? = null, getNode: (String?) -> Node): Flow<List<Message>>
}

View File

@@ -1060,6 +1060,11 @@
<string name="screen_on_for">Screen on for</string>
<string name="scroll_to_bottom">Scroll to bottom</string>
<string name="search_emoji">Search emoji...</string>
<string name="search_messages">Search messages…</string>
<plurals name="search_result_count">
<item quantity="one">%1$d result</item>
<item quantity="other">%1$d results</item>
</plurals>
<string name="secondary">Secondary</string>
<string name="secondary_channel_position_feature">Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required.</string>
<string name="secondary_no_telemetry">No periodic telemetry broadcast</string>

View File

@@ -90,6 +90,7 @@ import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
import org.meshtastic.feature.messaging.component.MessageMenuAction
import org.meshtastic.feature.messaging.component.MessageSearchBar
import org.meshtastic.feature.messaging.component.MessageTopBar
import org.meshtastic.feature.messaging.component.QuickChatRow
import org.meshtastic.feature.messaging.component.ReplySnippet
@@ -143,6 +144,9 @@ fun MessageScreen(
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
// Prevent the message TextField from stealing focus when the screen opens
LaunchedEffect(contactKey) { focusManager.clearFocus() }
@@ -317,6 +321,13 @@ fun MessageScreen(
}
},
)
} else if (isSearchActive) {
MessageSearchBar(
query = searchQuery,
onQueryChange = viewModel::setSearchQuery,
onClose = viewModel::closeSearch,
resultCount = searchResults.size,
)
} else {
MessageTopBar(
title = title,
@@ -336,6 +347,7 @@ fun MessageScreen(
showFiltered = showFiltered,
onToggleShowFiltered = viewModel::toggleShowFiltered,
onNavigateToFilterSettings = navigateToFilterSettings,
onSearchClick = viewModel::toggleSearch,
)
}
},

View File

@@ -26,10 +26,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
@@ -143,6 +145,43 @@ class MessageViewModel(
.flatMapLatest { packetRepository.getFilteredCountFlow(it) }
.stateInWhileSubscribed(0)
// region ── Search ──
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _isSearchActive = MutableStateFlow(false)
val isSearchActive: StateFlow<Boolean> = _isSearchActive.asStateFlow()
@OptIn(kotlinx.coroutines.FlowPreview::class)
val searchResults: StateFlow<List<Message>> =
combine(_searchQuery, contactKeyForPagedMessages) { query, contactKey -> query to contactKey }
.debounce(SEARCH_DEBOUNCE_MS)
.flatMapLatest { (query, contactKey) ->
if (query.length < MIN_SEARCH_LENGTH) {
flowOf(emptyList())
} else {
packetRepository.searchMessages(query, contactKey, ::getNode)
}
}
.stateInWhileSubscribed(emptyList())
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun toggleSearch() {
_isSearchActive.value = !_isSearchActive.value
if (!_isSearchActive.value) _searchQuery.value = ""
}
fun closeSearch() {
_isSearchActive.value = false
_searchQuery.value = ""
}
// endregion
init {
val contactKey = savedStateHandle.get<String>("contactKey")
if (contactKey != null) {
@@ -234,4 +273,9 @@ class MessageViewModel(
val unreadCount = packetRepository.getUnreadCount(contact)
if (unreadCount == 0) notificationManager.cancel(contact.hashCode())
}
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
private const val MIN_SEARCH_LENGTH = 2
}
}

View File

@@ -66,6 +66,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alert_bell_text
import org.meshtastic.core.resources.cancel_reply
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.clear_selection
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
@@ -85,6 +86,8 @@ import org.meshtastic.core.resources.quick_chat_show
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.replying_to
import org.meshtastic.core.resources.scroll_to_bottom
import org.meshtastic.core.resources.search_messages
import org.meshtastic.core.resources.search_result_count
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.component.MeshtasticTextDialog
@@ -102,6 +105,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.More
import org.meshtastic.core.ui.icon.Muted
import org.meshtastic.core.ui.icon.Reply
import org.meshtastic.core.ui.icon.Search
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.Settings
import org.meshtastic.core.ui.icon.Unmuted
@@ -299,6 +303,7 @@ fun MessageTopBar(
showFiltered: Boolean = false,
onToggleShowFiltered: () -> Unit = {},
onNavigateToFilterSettings: () -> Unit = {},
onSearchClick: () -> Unit = {},
) = TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -319,6 +324,12 @@ fun MessageTopBar(
}
},
actions = {
IconButton(onClick = onSearchClick) {
Icon(
imageVector = MeshtasticIcons.Search,
contentDescription = stringResource(Res.string.search_messages),
)
}
MessageTopBarActions(
showQuickChat = showQuickChat,
onToggleQuickChat = onToggleQuickChat,
@@ -642,3 +653,68 @@ fun String.limitBytes(maxBytes: Int): String {
}
// endregion
// region ── MessageSearchBar ──
/**
* M3 contextual search bar that replaces the standard MessageTopBar when search is active. Follows the M3 "find in
* page" pattern: back arrow + text field + result count + clear.
*
* This uses [TopAppBar] rather than [SearchBar] because we're filtering within an existing conversation (contextual
* search), not performing primary app-level navigation search.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageSearchBar(
query: String,
onQueryChange: (String) -> Unit,
onClose: () -> Unit,
resultCount: Int,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = MeshtasticIcons.ArrowBack,
contentDescription = stringResource(Res.string.navigate_back),
)
}
},
title = {
androidx.compose.material3.TextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(text = stringResource(Res.string.search_messages), style = MaterialTheme.typography.bodyLarge)
},
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
colors =
androidx.compose.material3.TextFieldDefaults.colors(
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
),
)
},
actions = {
if (query.isNotEmpty()) {
Text(
text = pluralStringResource(Res.plurals.search_result_count, resultCount, resultCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 4.dp),
)
IconButton(onClick = { onQueryChange("") }) {
Icon(imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.clear))
}
}
},
)
}
// endregion