feat: word-based message filtering with quarantine approach (stored but hidden) (#4241)

This commit is contained in:
Mac DeCourcy 2026-01-24 08:41:17 -08:00 committed by GitHub
parent ae65e64a37
commit c0f8ed3503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2187 additions and 115 deletions

File diff suppressed because it is too large Load diff

View file

@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@ -342,6 +343,129 @@ class PacketDaoTest {
assertTrue(updated.sfpp_hash?.contentEquals(hash) == true)
}
@Test
fun test_filteredMessages_excludedFromContactKeys(): Unit = runBlocking {
// Create a new contact with only filtered messages
val filteredContactKey = "0!filteredonly"
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
contact_key = filteredContactKey,
received_time = System.currentTimeMillis(),
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
filtered = true,
)
packetDao.insert(filteredPacket)
// getContactKeys should not include contacts with only filtered messages
val contactKeys = packetDao.getContactKeys().first()
assertFalse(contactKeys.containsKey(filteredContactKey))
}
@Test
fun test_getFilteredCount_returnsCorrectCount(): Unit = runBlocking {
val contactKey = "0${DataPacket.ID_BROADCAST}"
// Insert filtered messages
repeat(3) { i ->
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
contact_key = contactKey,
received_time = System.currentTimeMillis() + i,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered $i"),
filtered = true,
)
packetDao.insert(filteredPacket)
}
val filteredCount = packetDao.getFilteredCount(contactKey)
assertEquals(3, filteredCount)
}
@Test
fun test_contactFilteringDisabled_persistence(): Unit = runBlocking {
val contactKey = "0!testcontact"
// Initially should be null or false
val initial = packetDao.getContactFilteringDisabled(contactKey)
assertTrue(initial == null || initial == false)
// Set filtering disabled
packetDao.setContactFilteringDisabled(contactKey, true)
val disabled = packetDao.getContactFilteringDisabled(contactKey)
assertEquals(true, disabled)
// Re-enable filtering
packetDao.setContactFilteringDisabled(contactKey, false)
val enabled = packetDao.getContactFilteringDisabled(contactKey)
assertEquals(false, enabled)
}
@Test
fun test_getMessagesFrom_excludesFilteredMessages(): Unit = runBlocking {
val contactKey = "0!notificationtest"
// Insert mix of filtered and non-filtered messages
val normalMessages = listOf("Hello", "How are you?", "Good morning")
val filteredMessages = listOf("Filtered message 1", "Filtered message 2")
normalMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
contact_key = contactKey,
received_time = System.currentTimeMillis() + index,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = false,
)
packetDao.insert(packet)
}
filteredMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
contact_key = contactKey,
received_time = System.currentTimeMillis() + normalMessages.size + index,
read = true, // Filtered messages are marked as read
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = true,
)
packetDao.insert(packet)
}
// Without filter - should return all messages
val allMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(normalMessages.size + filteredMessages.size, allMessages.size)
// With includeFiltered = true - should return all messages
val includingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = true).first()
assertEquals(normalMessages.size + filteredMessages.size, includingFiltered.size)
// With includeFiltered = false - should only return non-filtered messages
val excludingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = false).first()
assertEquals(normalMessages.size, excludingFiltered.size)
// Verify none of the returned messages are filtered
val hasFilteredMessages = excludingFiltered.any { it.packet.filtered }
assertFalse(hasFilteredMessages)
}
companion object {
private const val SAMPLE_SIZE = 10
}

View file

@ -90,8 +90,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 29, to = 30, spec = AutoMigration29to30::class),
AutoMigration(from = 30, to = 31),
AutoMigration(from = 31, to = 32),
AutoMigration(from = 32, to = 33),
],
version = 32,
version = 33,
exportSchema = true,
)
@TypeConverters(Converters::class)

View file

@ -50,7 +50,7 @@ interface PacketDao {
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1
AND port_num = 1 AND filtered = 0
ORDER BY received_time DESC
""",
)
@ -69,11 +69,11 @@ interface PacketDao {
SELECT contact_key, MAX(received_time) as max_time
FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1
AND port_num = 1 AND filtered = 0
GROUP BY contact_key
) latest ON p.contact_key = latest.contact_key AND p.received_time = latest.max_time
WHERE (p.myNodeNum = 0 OR p.myNodeNum = (SELECT myNodeNum FROM my_node))
AND p.port_num = 1
AND p.port_num = 1 AND p.filtered = 0
ORDER BY p.received_time DESC
""",
)
@ -161,6 +161,18 @@ interface PacketDao {
)
fun getMessagesFrom(contact: String, limit: Int): Flow<List<PacketEntity>>
@Transaction
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact
AND (filtered = 0 OR :includeFiltered = 1)
ORDER BY received_time DESC
""",
)
fun getMessagesFrom(contact: String, includeFiltered: Boolean): Flow<List<PacketEntity>>
@Transaction
@Query(
"""
@ -376,6 +388,47 @@ interface PacketDao {
)
suspend fun findReactionBySfppHash(hash: ByteArray): ReactionEntity?
@Query(
"""
SELECT COUNT(*) FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact AND filtered = 1
""",
)
suspend fun getFilteredCount(contact: String): Int
@Query(
"""
SELECT COUNT(*) FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact AND filtered = 1
""",
)
fun getFilteredCountFlow(contact: String): Flow<Int>
@Transaction
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact
AND (filtered = 0 OR :includeFiltered = 1)
ORDER BY received_time DESC
""",
)
fun getMessagesFromPaged(contact: String, includeFiltered: Boolean): PagingSource<Int, PacketEntity>
@Query("SELECT filtering_disabled FROM contact_settings WHERE contact_key = :contact")
suspend fun getContactFilteringDisabled(contact: String): Boolean?
@Transaction
suspend fun setContactFilteringDisabled(contact: String, disabled: Boolean) {
val settings =
getContactSettings(contact)?.copy(filteringDisabled = disabled)
?: ContactSettings(contact_key = contact, filteringDisabled = disabled)
upsertContactSettings(listOf(settings))
}
@Transaction
suspend fun deleteAll() {
deleteAllPackets()

View file

@ -57,6 +57,7 @@ data class PacketEntity(
relayNode = data.relayNode,
relays = data.relays,
retryCount = data.retryCount,
filtered = filtered,
)
}
}
@ -87,6 +88,7 @@ data class Packet(
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
) {
companion object {
const val RELAY_NODE_SUFFIX_MASK = 0xFF
@ -120,6 +122,7 @@ data class ContactSettings(
val muteUntil: Long = 0L,
@ColumnInfo(name = "last_read_message_uuid") val lastReadMessageUuid: Long? = null,
@ColumnInfo(name = "last_read_message_timestamp") val lastReadMessageTimestamp: Long? = null,
@ColumnInfo(name = "filtering_disabled", defaultValue = "0") val filteringDisabled: Boolean = false,
) {
val isMuted
get() = System.currentTimeMillis() <= muteUntil

View file

@ -92,6 +92,7 @@ data class Message(
val relayNode: Int? = null,
val relays: Int = 0,
val retryCount: Int = 0,
val filtered: Boolean = false,
) {
fun getStatusStringRes(): Pair<StringResource, StringResource> {
val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status