From 0e59ed7a135180bb52b046fdeb7788ea94c0e197 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:52:50 -0500 Subject: [PATCH] feat: add FTS5 full-text message search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .skills/compose-ui/strings-index.txt | 2 + .../data/repository/PacketRepositoryImpl.kt | 51 + .../39.json | 1095 +++++++++++++++++ .../core/database/DatabaseManager.kt | 23 + .../core/database/MeshtasticDatabase.kt | 9 +- .../meshtastic/core/database/dao/PacketDao.kt | 25 + .../meshtastic/core/database/entity/Packet.kt | 1 + .../core/database/entity/PacketFts.kt | 29 + .../core/repository/PacketRepository.kt | 10 + .../composeResources/values/strings.xml | 5 + .../meshtastic/feature/messaging/Message.kt | 12 + .../feature/messaging/MessageViewModel.kt | 44 + .../component/MessageScreenComponents.kt | 76 ++ 13 files changed, 1380 insertions(+), 2 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 5e81cfb76..5a0a5d1d5 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index c47fe5bf1..1fa602409 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -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> { + 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 diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json new file mode 100644 index 000000000..4ac92bf0d --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json @@ -0,0 +1,1095 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "69fb4477a86e5ba8c47876cbb3035839", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '69fb4477a86e5ba8c47876cbb3035839')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index e96ce887c..5ecee5f9d 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -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() diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index d329d184c..6eade9488 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -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 RoomDatabase.Builder.configureCommon(): RoomDatabase.Builder = - this.fallbackToDestructiveMigration(dropAllTables = false).setQueryCoroutineContext(ioDispatcher) + this.fallbackToDestructiveMigration(dropAllTables = false) + .setMultipleConnectionPool(maxNumOfReaders = 4, maxNumOfWriters = 1) + .setQueryCoroutineContext(ioDispatcher) } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 2aef7ef6d..6178a3d33 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -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 + + @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 + + @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 } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 0a9ea4aa2..ec5889fdf 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -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 diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt new file mode 100644 index 000000000..1e7e54583 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/PacketFts.kt @@ -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 . + */ +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) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt index 491c3e193..4f83ff2ab 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -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> } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 0f3cf4e28..821e51a8d 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1060,6 +1060,11 @@ Screen on for Scroll to bottom Search emoji... + Search messages… + + %1$d result + %1$d results + Secondary Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required. No periodic telemetry broadcast diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index e88a73077..2994fb7e3 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -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, ) } }, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index ca29b3842..196fd308a 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -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 = _searchQuery.asStateFlow() + + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive.asStateFlow() + + @OptIn(kotlinx.coroutines.FlowPreview::class) + val searchResults: StateFlow> = + 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("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 + } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt index dba6bb493..7e4fcb6b4 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt @@ -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