|
| 1 | +/* |
| 2 | + * Copyright (c) 2026 Meshtastic LLC |
| 3 | + * |
| 4 | + * This program is free software: you can redistribute it and/or modify |
| 5 | + * it under the terms of the GNU General Public License as published by |
| 6 | + * the Free Software Foundation, either version 3 of the License, or |
| 7 | + * (at your option) any later version. |
| 8 | + * |
| 9 | + * This program is distributed in the hope that it will be useful, |
| 10 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | + * GNU General Public License for more details. |
| 13 | + * |
| 14 | + * You should have received a copy of the GNU General Public License |
| 15 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 16 | + */ |
| 17 | +package org.meshtastic.core.database.dao |
| 18 | + |
| 19 | +import androidx.room3.Room |
| 20 | +import androidx.sqlite.driver.bundled.BundledSQLiteDriver |
| 21 | +import androidx.test.core.app.ApplicationProvider |
| 22 | +import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 23 | +import kotlinx.coroutines.test.runTest |
| 24 | +import org.junit.After |
| 25 | +import org.junit.Before |
| 26 | +import org.junit.Test |
| 27 | +import org.junit.runner.RunWith |
| 28 | +import org.meshtastic.core.common.util.nowMillis |
| 29 | +import org.meshtastic.core.database.MeshtasticDatabase |
| 30 | +import org.meshtastic.core.database.MeshtasticDatabaseConstructor |
| 31 | +import org.meshtastic.core.database.entity.MyNodeEntity |
| 32 | +import org.meshtastic.core.database.entity.Packet |
| 33 | +import org.meshtastic.core.model.DataPacket |
| 34 | +import org.meshtastic.core.model.NodeAddress |
| 35 | +import org.meshtastic.proto.PortNum |
| 36 | +import org.robolectric.annotation.Config |
| 37 | +import kotlin.test.assertEquals |
| 38 | +import kotlin.test.assertTrue |
| 39 | + |
| 40 | +/** |
| 41 | + * Verifies FTS5 full-text message search (#5373) and the historical-message backfill. |
| 42 | + * |
| 43 | + * [backfillMessageTexts_makesHistoricalMessagesSearchable] is the regression guard for the original `json_extract(data, |
| 44 | + * '$.text')` backfill, which silently matched nothing: `DataPacket.text` is a computed property and is never serialized |
| 45 | + * into the stored JSON, so historical messages stayed permanently unsearchable. |
| 46 | + */ |
| 47 | +@RunWith(AndroidJUnit4::class) |
| 48 | +@Config(sdk = [34]) |
| 49 | +class PacketFtsSearchTest { |
| 50 | + private lateinit var database: MeshtasticDatabase |
| 51 | + private lateinit var packetDao: PacketDao |
| 52 | + private lateinit var nodeInfoDao: NodeInfoDao |
| 53 | + |
| 54 | + private val myNodeNum = 42424242 |
| 55 | + |
| 56 | + private val myNodeInfo = |
| 57 | + MyNodeEntity( |
| 58 | + myNodeNum = myNodeNum, |
| 59 | + model = null, |
| 60 | + firmwareVersion = null, |
| 61 | + couldUpdate = false, |
| 62 | + shouldUpdate = false, |
| 63 | + currentPacketId = 1L, |
| 64 | + messageTimeoutMsec = 5 * 60 * 1000, |
| 65 | + minAppVersion = 1, |
| 66 | + maxChannels = 8, |
| 67 | + hasWifi = false, |
| 68 | + ) |
| 69 | + |
| 70 | + @Before |
| 71 | + fun createDb(): Unit = runTest { |
| 72 | + val context = ApplicationProvider.getApplicationContext<android.content.Context>() |
| 73 | + database = |
| 74 | + Room.inMemoryDatabaseBuilder<MeshtasticDatabase>( |
| 75 | + context = context, |
| 76 | + factory = { MeshtasticDatabaseConstructor.initialize() }, |
| 77 | + ) |
| 78 | + .setDriver(BundledSQLiteDriver()) |
| 79 | + .build() |
| 80 | + nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) } |
| 81 | + packetDao = database.packetDao() |
| 82 | + } |
| 83 | + |
| 84 | + @After |
| 85 | + fun closeDb() { |
| 86 | + database.close() |
| 87 | + } |
| 88 | + |
| 89 | + @Test |
| 90 | + fun searchMessages_matchesIndexedText_ignoresNonMatches() = runTest { |
| 91 | + insertTextPacket(contactKey = CONTACT, text = "the quick brown fox", messageText = "the quick brown fox") |
| 92 | + |
| 93 | + assertEquals(1, packetDao.searchMessages("brown").size, "a term in the indexed message should match") |
| 94 | + assertTrue(packetDao.searchMessages("zebra").isEmpty(), "an absent term should not match") |
| 95 | + } |
| 96 | + |
| 97 | + @Test |
| 98 | + fun searchMessagesInConversation_scopesToContact() = runTest { |
| 99 | + insertTextPacket(contactKey = CONTACT, text = "shared keyword here", messageText = "shared keyword here") |
| 100 | + insertTextPacket(contactKey = OTHER_CONTACT, text = "shared keyword here", messageText = "shared keyword here") |
| 101 | + |
| 102 | + assertEquals(2, packetDao.searchMessages("keyword").size, "both conversations match the global search") |
| 103 | + assertEquals(1, packetDao.searchMessagesInConversation("keyword", CONTACT).size, "scoped to one contact") |
| 104 | + } |
| 105 | + |
| 106 | + @Test |
| 107 | + fun backfillMessageTexts_makesHistoricalMessagesSearchable() = runTest { |
| 108 | + // A pre-v39 packet: the payload carries the text, but message_text was never populated, so it is unindexed. |
| 109 | + insertTextPacket(contactKey = CONTACT, text = "historical needle", messageText = "") |
| 110 | + |
| 111 | + assertTrue(packetDao.searchMessages("needle").isEmpty(), "the historical message is unindexed before backfill") |
| 112 | + assertEquals(1, packetDao.countPacketsNeedingBackfill(), "the empty-message_text packet needs backfill") |
| 113 | + |
| 114 | + val updated = packetDao.backfillMessageTexts() |
| 115 | + packetDao.rebuildFtsIndex() |
| 116 | + |
| 117 | + assertEquals(1, updated, "the historical text packet should be backfilled") |
| 118 | + assertEquals(1, packetDao.searchMessages("needle").size, "the backfilled message should now be searchable") |
| 119 | + assertEquals(0, packetDao.countPacketsNeedingBackfill(), "nothing left to backfill") |
| 120 | + } |
| 121 | + |
| 122 | + private suspend fun insertTextPacket(contactKey: String, text: String, messageText: String) { |
| 123 | + packetDao.insert( |
| 124 | + Packet( |
| 125 | + uuid = 0L, |
| 126 | + myNodeNum = myNodeNum, |
| 127 | + port_num = PortNum.TEXT_MESSAGE_APP.value, |
| 128 | + contact_key = contactKey, |
| 129 | + received_time = nowMillis, |
| 130 | + read = false, |
| 131 | + data = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 0, text = text), |
| 132 | + messageText = messageText, |
| 133 | + ), |
| 134 | + ) |
| 135 | + } |
| 136 | + |
| 137 | + companion object { |
| 138 | + private const val CONTACT = "0!aaaa1111" |
| 139 | + private const val OTHER_CONTACT = "0!bbbb2222" |
| 140 | + } |
| 141 | +} |
0 commit comments