From eebc6ca841e83bb22a8d0aeb05579075b157dc6d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:52:50 -0500 Subject: [PATCH 1/5] feat: add FTS5 full-text message search Adds full-text search to the messaging screen using SQLite FTS5: - New PacketFts entity (content-sync FTS5 table) tracking message_text - Search UI with contextual search bar, prev/next navigation, and result count - Term highlighting in message bubbles during active search - Scroll-to-match when navigating between search results - Efficient backfill via json_extract SQL UPDATE (no OOM risk) - Query sanitization wrapping tokens in quotes to escape FTS5 special chars - 300ms debounce with minimum 2-char threshold Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 1 + .../data/repository/PacketRepositoryImpl.kt | 51 + .../39.json | 1095 +++++++++++++++++ .../core/database/DatabaseManager.kt | 16 + .../core/database/MeshtasticDatabase.kt | 5 +- .../meshtastic/core/database/dao/PacketDao.kt | 32 + .../meshtastic/core/database/entity/Packet.kt | 1 + .../core/database/entity/PacketFts.kt | 29 + .../core/repository/PacketRepository.kt | 10 + .../composeResources/values/strings.xml | 1 + .../core/ui/component/AutoLinkText.kt | 77 ++ .../meshtastic/feature/messaging/Message.kt | 27 + .../feature/messaging/MessageListPaged.kt | 2 + .../feature/messaging/MessageViewModel.kt | 70 ++ .../messaging/component/MessageItem.kt | 17 +- .../component/MessageScreenComponents.kt | 97 ++ 16 files changed, 1529 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 f6884c99d5..2a4b894633 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1114,6 +1114,7 @@ scanning_network screen_on_for scroll_to_bottom search_emoji +search_messages 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 c47fe5bf15..1fa6024092 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 0000000000..4ac92bf0db --- /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 42dd56a6af..e082cfc50e 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)}" } } @@ -305,6 +308,19 @@ open class DatabaseManager( datastore.edit { it[legacyCleanedKey] = true } } + /** + * 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. + */ + private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) { + val count = db.packetDao().backfillMessageTexts() + if (count == 0) return + Logger.i { "Backfilled $count messages for FTS search index" } + db.packetDao().rebuildFtsIndex() + Logger.i { "FTS search index rebuild 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 b46d3b360a..6eade94889 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) 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 2aef7ef6d2..8199e0ba73 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,36 @@ 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( + "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("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 5a16fd7b1a..e4b7727524 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 0000000000..1e7e545835 --- /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 491c3e193f..4f83ff2abe 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 6c59d355d7..b42b0189f2 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1156,6 +1156,7 @@ Screen on for Scroll to bottom Search emoji... + Search messages… 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/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt index 7ee7dcb02d..af4909e263 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.core.ui.component +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -27,8 +28,10 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle import org.meshtastic.core.ui.theme.HyperlinkBlue private val DefaultTextLinkStyles = @@ -90,3 +93,77 @@ private fun buildAnnotatedStringWithLinks(text: String, linkStyles: TextLinkStyl range.forEach { usedIndices.add(it) } } } + +/** + * A [Text] component that highlights occurrences of [query] within [text] using the tertiary container color. Each + * matching token in the query is highlighted independently (case-insensitive). + */ +@Composable +fun HighlightedText( + text: String, + query: String, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + color: Color = Color.Unspecified, +) { + val highlightColor = MaterialTheme.colorScheme.tertiaryContainer + val highlightContentColor = MaterialTheme.colorScheme.onTertiaryContainer + val annotatedString = + remember(text, query, highlightColor, highlightContentColor) { + buildHighlightedString(text, query, highlightColor, highlightContentColor) + } + Text(text = annotatedString, modifier = modifier, style = style.copy(color = color)) +} + +private fun buildHighlightedString( + text: String, + query: String, + highlightColor: Color, + contentColor: Color, +): AnnotatedString = buildAnnotatedString { + val lowerText = text.lowercase() + val tokens = query.split("\\s+".toRegex()).filter { it.isNotBlank() }.map { it.lowercase() } + if (tokens.isEmpty()) { + append(text) + return@buildAnnotatedString + } + + // Find all match ranges + val matchRanges = mutableListOf() + for (token in tokens) { + var start = 0 + while (start < lowerText.length) { + val matchStart = lowerText.indexOf(token, start) + if (matchStart == -1) break + matchRanges.add(matchStart until matchStart + token.length) + start = matchStart + token.length + } + } + + // Merge overlapping ranges and sort + val merged = mergeRanges(matchRanges.sortedBy { it.first }) + + val highlightStyle = SpanStyle(background = highlightColor, color = contentColor, fontWeight = FontWeight.Bold) + + var cursor = 0 + for (range in merged) { + if (range.first > cursor) append(text.substring(cursor, range.first)) + withStyle(highlightStyle) { append(text.substring(range.first, range.last + 1)) } + cursor = range.last + 1 + } + if (cursor < text.length) append(text.substring(cursor)) +} + +private fun mergeRanges(sorted: List): List { + if (sorted.isEmpty()) return emptyList() + val result = mutableListOf(sorted.first()) + for (range in sorted.drop(1)) { + val last = result.last() + if (range.first <= last.last + 1) { + result[result.lastIndex] = last.first..maxOf(last.last, range.last) + } else { + result.add(range) + } + } + return result +} 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 89ac0ef45b..3047b4212e 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,11 @@ 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() + val searchResultIndex by viewModel.searchResultIndex.collectAsStateWithLifecycle() + val currentSearchResult by viewModel.currentSearchResult.collectAsStateWithLifecycle() // Prevent the message TextField from stealing focus when the screen opens LaunchedEffect(contactKey) { focusManager.clearFocus() } @@ -225,6 +231,15 @@ fun MessageScreen( } } + // Scroll to the current search result when navigating prev/next + LaunchedEffect(currentSearchResult) { + val targetUuid = currentSearchResult?.uuid ?: return@LaunchedEffect + val index = pagedMessages.itemSnapshotList.indexOfFirst { it?.uuid == targetUuid } + if (index != -1) { + listState.animateScrollToItem(index) + } + } + val onEvent: (MessageScreenEvent) -> Unit = remember(viewModel, contactKey, messageInputState, ourNode) { fun handle(event: MessageScreenEvent) { @@ -317,6 +332,16 @@ fun MessageScreen( } }, ) + } else if (isSearchActive) { + MessageSearchBar( + query = searchQuery, + onQueryChange = viewModel::setSearchQuery, + onClose = viewModel::closeSearch, + resultCount = searchResults.size, + currentIndex = searchResultIndex, + onPrevious = viewModel::navigateToPreviousResult, + onNext = viewModel::navigateToNextResult, + ) } else { MessageTopBar( title = title, @@ -336,6 +361,7 @@ fun MessageScreen( showFiltered = showFiltered, onToggleShowFiltered = viewModel::toggleShowFiltered, onNavigateToFilterSettings = navigateToFilterSettings, + onSearchClick = viewModel::toggleSearch, ) } }, @@ -389,6 +415,7 @@ fun MessageScreen( filteredCount = filteredCount, showFiltered = showFiltered, filteringDisabled = filteringDisabled, + searchQuery = if (isSearchActive) searchQuery else "", ), handlers = MessageListHandlers( diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 3f92f3cbf7..d8de5bb955 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -82,6 +82,7 @@ internal data class MessageListPagedState( val filteredCount: Int = 0, val showFiltered: Boolean = false, val filteringDisabled: Boolean = false, + val searchQuery: String = "", ) private fun MutableState>.toggle(uuid: Long) { @@ -367,6 +368,7 @@ private fun RenderPagedChatMessageRow( hasSamePrev = hasSamePrev, hasSameNext = hasSameNext, quickEmojis = quickEmojis, + searchQuery = state.searchQuery, ) } 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 ca29b38421..ee57f55ed3 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 @@ -21,15 +21,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow 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 +146,68 @@ 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() + + private val _searchResultIndex = MutableStateFlow(0) + val searchResultIndex: StateFlow = _searchResultIndex.asStateFlow() + + @OptIn(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()) + + /** The currently focused search result message (for scroll-to-match). */ + val currentSearchResult: StateFlow = + combine(searchResults, _searchResultIndex) { results, index -> results.getOrNull(index) } + .stateInWhileSubscribed(null) + + fun setSearchQuery(query: String) { + _searchQuery.value = query + _searchResultIndex.value = 0 + } + + fun navigateToNextResult() { + val max = searchResults.value.size + if (max == 0) return + _searchResultIndex.update { (it + 1) % max } + } + + fun navigateToPreviousResult() { + val max = searchResults.value.size + if (max == 0) return + _searchResultIndex.update { if (it == 0) max - 1 else it - 1 } + } + + fun toggleSearch() { + _isSearchActive.value = !_isSearchActive.value + if (!_isSearchActive.value) { + _searchQuery.value = "" + _searchResultIndex.value = 0 + } + } + + fun closeSearch() { + _isSearchActive.value = false + _searchQuery.value = "" + _searchResultIndex.value = 0 + } + + // endregion + init { val contactKey = savedStateHandle.get("contactKey") if (contactKey != null) { @@ -234,4 +299,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/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 81e29fb09b..ff93c3e738 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -66,6 +66,7 @@ import org.meshtastic.core.resources.a11y_message_from import org.meshtastic.core.resources.filter_message_label import org.meshtastic.core.resources.reply import org.meshtastic.core.ui.component.AutoLinkText +import org.meshtastic.core.ui.component.HighlightedText import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.component.Snr @@ -103,6 +104,7 @@ fun MessageItem( onStatusClick: () -> Unit = {}, hasSamePrev: Boolean = false, hasSameNext: Boolean = false, + searchQuery: String = "", ) = Column( modifier = modifier @@ -260,7 +262,20 @@ fun MessageItem( ) Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { - AutoLinkText(text = message.text, style = MaterialTheme.typography.bodyLarge, color = contentColor) + if (searchQuery.isNotEmpty()) { + HighlightedText( + text = message.text, + query = searchQuery, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + } else { + AutoLinkText( + text = message.text, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + } Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { if (!message.fromLocal) { 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 4d89b342c2..2482c3341d 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 @@ -48,6 +48,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -57,6 +59,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope @@ -70,6 +73,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 @@ -89,6 +93,7 @@ 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.select_all import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.component.MeshtasticTextDialog @@ -102,10 +107,13 @@ import org.meshtastic.core.ui.icon.Copy import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.FilterList import org.meshtastic.core.ui.icon.FilterListOff +import org.meshtastic.core.ui.icon.KeyboardArrowDown +import org.meshtastic.core.ui.icon.KeyboardArrowUp 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 @@ -303,6 +311,7 @@ fun MessageTopBar( showFiltered: Boolean = false, onToggleShowFiltered: () -> Unit = {}, onNavigateToFilterSettings: () -> Unit = {}, + onSearchClick: () -> Unit = {}, ) = TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -323,6 +332,12 @@ fun MessageTopBar( } }, actions = { + IconButton(onClick = onSearchClick) { + Icon( + imageVector = MeshtasticIcons.Search, + contentDescription = stringResource(Res.string.search_messages), + ) + } MessageTopBarActions( showQuickChat = showQuickChat, onToggleQuickChat = onToggleQuickChat, @@ -650,3 +665,85 @@ 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 + "X of Y" counter + prev/next arrows + 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, + currentIndex: Int = 0, + onPrevious: () -> Unit = {}, + onNext: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = MeshtasticIcons.ArrowBack, + contentDescription = stringResource(Res.string.navigate_back), + ) + } + }, + title = { + 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 = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + }, + actions = { + if (query.isNotEmpty() && resultCount > 0) { + Text( + text = "${currentIndex + 1} / $resultCount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 4.dp), + ) + IconButton(onClick = onPrevious) { + Icon( + imageVector = MeshtasticIcons.KeyboardArrowUp, + contentDescription = stringResource(Res.string.search_messages), + ) + } + IconButton(onClick = onNext) { + Icon( + imageVector = MeshtasticIcons.KeyboardArrowDown, + contentDescription = stringResource(Res.string.search_messages), + ) + } + } + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(imageVector = MeshtasticIcons.Close, contentDescription = stringResource(Res.string.clear)) + } + } + }, + ) +} + +// endregion From 05b446f81634100ea630e5f056c7d563570abfcf Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 31 May 2026 08:58:32 -0500 Subject: [PATCH 2/5] fix(database): prevent DB leaks and improve FTS backfill stability - Wrap backfill writes in NonCancellable to prevent connection pool leaks when coroutine scope is cancelled mid-write - Add countPacketsNeedingBackfill() query to avoid starting a write transaction when no work is needed - Use BundledSQLiteDriver in DatabaseBuilder for consistent test behavior Found during release/2.8.0 integration testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/database/DatabaseBuilder.kt | 2 ++ .../core/database/DatabaseManager.kt | 19 ++++++++++++++----- .../meshtastic/core/database/dao/PacketDao.kt | 7 +++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt index 84e00ca696..215254dd48 100644 --- a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt +++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -24,6 +24,7 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room3.Room import androidx.room3.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import okio.FileSystem import okio.Path import okio.Path.Companion.toPath @@ -37,6 +38,7 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder 0 + if (!needsBackfill) return + + // Perform the write operations inside NonCancellable to prevent + // connection pool leaks due to coroutine cancellation. + withContext(NonCancellable) { + val count = db.packetDao().backfillMessageTexts() + if (count > 0) { + Logger.i { "Backfilled $count messages for FTS search index" } + db.packetDao().rebuildFtsIndex() + Logger.i { "FTS search index rebuild complete" } + } + } } /** Closes all open databases and cancels background work. */ 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 8199e0ba73..29068a7c16 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 @@ -568,6 +568,13 @@ 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", + ) + 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 = '') " + From 9b7a3040512bff7228c2d39d1b57be64d06584ce Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 31 May 2026 09:10:59 -0500 Subject: [PATCH 3/5] style: fix import ordering in DatabaseManager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/core/database/DatabaseManager.kt | 2 +- core/proto/src/main/proto | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 427ef1e43c..92db4c4e7c 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 @@ -26,6 +26,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +39,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import org.koin.core.annotation.Named import org.koin.core.annotation.Single diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index a0a2239c6f..e3c8af5cdc 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit a0a2239c6fc08bc70499dd04a5c72b78d6e9b265 +Subproject commit e3c8af5cdce6e91fc960d05fd209a7e19dd738fa From ae6228d9f3ad6b96bd96c8282d09aa78a9d4f85c Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 31 May 2026 09:20:26 -0500 Subject: [PATCH 4/5] fix: remove BundledSQLiteDriver from Android DatabaseBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android framework SQLite supports FTS5 at API 26+ (our minSdk). BundledSQLiteDriver loads a JNI native lib that causes UnsatisfiedLinkError in Robolectric unit tests. Removing it entirely from the Android source set — framework SQLite handles both production and in-memory test databases correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/core/database/DatabaseBuilder.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt index 215254dd48..84e00ca696 100644 --- a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt +++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -24,7 +24,6 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room3.Room import androidx.room3.RoomDatabase -import androidx.sqlite.driver.bundled.BundledSQLiteDriver import okio.FileSystem import okio.Path import okio.Path.Companion.toPath @@ -38,7 +37,6 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder Date: Sun, 31 May 2026 10:05:47 -0500 Subject: [PATCH 5/5] fix: use BundledSQLiteDriver for FTS5 support in Robolectric tests Room's default AndroidSQLiteDriver delegates to framework SQLite, which under Robolectric is shadowed by a native SQLite build that lacks the FTS5 extension. This causes 'no such module: FTS5' errors when the PacketFts entity triggers table creation. Fix by: 1. Setting BundledSQLiteDriver on both getDatabaseBuilder and getInMemoryDatabaseBuilder (bundled SQLite includes FTS5) 2. Adding sqlite-bundled-jvm as testRuntimeOnly in androidApp and runtimeOnly in core:database androidHostTest to provide the host-platform native library for JVM-based test execution 3. Using BundledSQLiteDriver in MigrationTest's in-memory builder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- androidApp/build.gradle.kts | 2 ++ core/database/build.gradle.kts | 2 ++ .../kotlin/org/meshtastic/core/database/dao/MigrationTest.kt | 2 ++ .../kotlin/org/meshtastic/core/database/DatabaseBuilder.kt | 3 +++ 4 files changed, 9 insertions(+) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 480989d8a1..8cba710bc9 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -296,4 +296,6 @@ dependencies { testImplementation(libs.compose.multiplatform.ui.test) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.glance.appwidget) + // JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric + testRuntimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2") } diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 69ed36b5d6..9e35dcc4bd 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -53,6 +53,8 @@ kotlin { val androidHostTest by getting { dependencies { implementation(libs.androidx.sqlite.bundled) + // JVM variant provides the host-platform native for BundledSQLiteDriver + runtimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.6.2") implementation(libs.androidx.room.testing) implementation(libs.androidx.test.ext.junit) implementation(libs.junit) diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 451a621740..f992cce6d4 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -17,6 +17,7 @@ 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.flow.first @@ -66,6 +67,7 @@ class MigrationTest { context = context, factory = { MeshtasticDatabaseConstructor.initialize() }, ) + .setDriver(BundledSQLiteDriver()) .build() nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) } packetDao = database.packetDao() diff --git a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt index 84e00ca696..8e9fbbac23 100644 --- a/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt +++ b/core/database/src/androidMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt @@ -24,6 +24,7 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room3.Room import androidx.room3.RoomDatabase +import androidx.sqlite.driver.bundled.BundledSQLiteDriver import okio.FileSystem import okio.Path import okio.Path.Companion.toPath @@ -38,12 +39,14 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder = Room.inMemoryDatabaseBuilder(factory = { MeshtasticDatabaseConstructor.initialize() }) .configureCommon() + .setDriver(BundledSQLiteDriver()) /** Returns the Android directory where database files are stored. */ actual fun getDatabaseDirectory(): Path {