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

3
.gitignore vendored
View file

@ -45,3 +45,6 @@ keystore.properties
# Personal build scripts
build-and-install-android.sh
wireless-install.sh
# Git worktrees
.worktrees/

View file

@ -244,7 +244,9 @@ dependencies {
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit)
testImplementation(libs.mockk)

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.filter
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.service.filter.MessageFilterService
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MessageFilterIntegrationTest {
@get:Rule var hiltRule = HiltAndroidRule(this)
@Inject lateinit var filterPrefs: FilterPrefs
@Inject lateinit var filterService: MessageFilterService
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun filterPrefsIntegration() = runTest {
filterPrefs.filterEnabled = true
filterPrefs.filterWords = setOf("test", "spam")
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is a test message"))
assertTrue(filterService.shouldFilter("spam content"))
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("Wrapping", "SpacingAroundColon")
package com.geeksville.mesh.navigation
@ -35,6 +34,7 @@ import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
@ -175,6 +175,8 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
}
composable<SettingsRoutes.About> { AboutScreen(onNavigateUp = navController::navigateUp) }
composable<SettingsRoutes.FilterSettings> { FilterSettingsScreen(onBack = navController::navigateUp) }
}
}

View file

@ -41,6 +41,7 @@ import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.RetryEvent
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.error_duty_cycle
@ -81,6 +82,7 @@ constructor(
private val tracerouteHandler: MeshTracerouteHandler,
private val neighborInfoHandler: MeshNeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
private val messageFilterService: MessageFilterService,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -631,20 +633,6 @@ constructor(
// contactKey: unique contact key filter (channel)+(nodeId)
val contactKey = "${dataPacket.channel}$contactId"
val packetToSave =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
packetId = dataPacket.id,
port_num = dataPacket.dataType,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
read = fromLocal,
data = dataPacket,
snr = dataPacket.snr,
rssi = dataPacket.rssi,
hopsAway = dataPacket.hopsAway,
)
scope.handledLaunch {
packetRepository.get().apply {
// Check for duplicates before inserting
@ -658,23 +646,59 @@ constructor(
return@handledLaunch
}
insert(packetToSave)
val conversationMuted = getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
dataPacket.alert ?: getString(Res.string.critical_alert),
// Check if message should be filtered
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
val packetToSave =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
packetId = dataPacket.id,
port_num = dataPacket.dataType,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
read = fromLocal || isFiltered,
data = dataPacket,
snr = dataPacket.snr,
rssi = dataPacket.rssi,
hopsAway = dataPacket.hopsAway,
filtered = isFiltered,
)
} else if (updateNotification) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
insert(packetToSave)
if (!isFiltered) {
handlePacketNotification(packetToSave, dataPacket, contactKey, updateNotification)
}
}
}
}
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
if (dataPacket.dataType != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) return false
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
}
private suspend fun handlePacketNotification(
packet: Packet,
dataPacket: DataPacket,
contactKey: String,
updateNotification: Boolean,
) {
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (packet.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
dataPacket.alert ?: getString(Res.string.critical_alert),
)
} else if (updateNotification) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
private fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
@ -758,6 +782,9 @@ constructor(
// Find the original packet to get the contactKey
packetRepository.get().getPacketByPacketId(packet.decoded.replyId)?.let { original ->
// Skip notification if the original message was filtered
if (original.packet.filtered) return@let
val contactKey = original.packet.contact_key
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true

View file

@ -350,7 +350,7 @@ constructor(
val history =
packetRepository
.get()
.getMessagesFrom(contactKey) { nodeId ->
.getMessagesFrom(contactKey, includeFiltered = false) { nodeId ->
if (nodeId == DataPacket.ID_LOCAL) {
ourNode ?: nodeRepository.get().getNode(nodeId)
} else {

View file

@ -197,6 +197,10 @@ constructor(
fun getContactSettings() = packetRepository.getContactSettings()
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
/**
* Get the total message count for a list of contact keys. This queries the repository directly, so it works even if
* contacts aren't loaded in the paged list.

View file

@ -56,10 +56,10 @@ class FakeNodeInfoWriteDataSource : NodeInfoWriteDataSource {
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {}
override suspend fun clearNodeDB(preserveFavorites: Boolean) {}
override suspend fun clearMyNodeInfo() {}
override suspend fun clearNodeDB(preserveFavorites: Boolean) {}
override suspend fun deleteNode(num: Int) {}
override suspend fun deleteNodes(nodeNums: List<Int>) {}

View file

@ -101,21 +101,30 @@ constructor(
suspend fun insert(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
suspend fun getMessagesFrom(contact: String, limit: Int? = null, getNode: suspend (String?) -> Node) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val flow = if (limit != null) dao.getMessagesFrom(contact, limit) else dao.getMessagesFrom(contact)
flow.mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
suspend fun getMessagesFrom(
contact: String,
limit: Int? = null,
includeFiltered: Boolean = true,
getNode: suspend (String?) -> Node,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val flow =
when {
limit != null -> dao.getMessagesFrom(contact, limit)
!includeFiltered -> dao.getMessagesFrom(contact, includeFiltered = false)
else -> dao.getMessagesFrom(contact)
}
flow.mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
}
fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow<PagingData<Message>> = Pager(
config =
@ -286,6 +295,43 @@ constructor(
suspend fun findReactionsWithId(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(packetId) }
fun getFilteredCountFlow(contactKey: String): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(contactKey) }
suspend fun getFilteredCount(contactKey: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(contactKey) }
fun getMessagesFromPaged(
contactKey: String,
includeFiltered: Boolean,
getNode: suspend (String?) -> Node,
): Flow<PagingData<Message>> = Pager(
config =
PagingConfig(
pageSize = MESSAGES_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = MESSAGES_PAGE_SIZE,
),
pagingSourceFactory = {
dbManager.currentDb.value.packetDao().getMessagesFromPaged(contactKey, includeFiltered)
},
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().setContactFilteringDisabled(contactKey, disabled)
}
suspend fun clearPacketDB() = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() }
suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) =

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

View file

@ -58,10 +58,12 @@ class DataPacketTest {
fun `DataPacket equals and hashCode include sfppHash`() {
val hash1 = byteArrayOf(1, 2, 3)
val hash2 = byteArrayOf(4, 5, 6)
val p1 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash1)
val p2 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash1)
val p3 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash2)
val p4 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = null)
val fixedTime = 1000L
val base = DataPacket(to = "to", channel = 0, text = "text").copy(time = fixedTime)
val p1 = base.copy(sfppHash = hash1)
val p2 = base.copy(sfppHash = hash1.copyOf()) // same content, different array instance
val p3 = base.copy(sfppHash = hash2)
val p4 = base.copy(sfppHash = null)
assertEquals(p1, p2)
assertEquals(p1.hashCode(), p2.hashCode())

View file

@ -151,6 +151,8 @@ object SettingsRoutes {
@Serializable data object About : Route
@Serializable data object FilterSettings : Route
// endregion
}

View file

@ -40,4 +40,9 @@ plugins {
configure<LibraryExtension> { namespace = "org.meshtastic.core.prefs" }
dependencies { googleImplementation(libs.maps.compose) }
dependencies {
googleImplementation(libs.maps.compose)
testImplementation(libs.junit)
testImplementation(libs.mockk)
}

View file

@ -28,6 +28,8 @@ import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import org.meshtastic.core.prefs.analytics.AnalyticsPrefsImpl
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefsImpl
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.prefs.filter.FilterPrefsImpl
import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.prefs.map.MapConsentPrefsImpl
import org.meshtastic.core.prefs.map.MapPrefs
@ -88,6 +90,10 @@ internal annotation class UiSharedPreferences
@Retention(AnnotationRetention.BINARY)
internal annotation class MeshLogSharedPreferences
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class FilterSharedPreferences
@Suppress("TooManyFunctions")
@InstallIn(SingletonComponent::class)
@Module
@ -111,6 +117,8 @@ interface PrefsModule {
@Binds fun bindUiPrefs(uiPrefsImpl: UiPrefsImpl): UiPrefs
@Binds fun bindFilterPrefs(filterPrefsImpl: FilterPrefsImpl): FilterPrefs
companion object {
@Provides
@ -172,5 +180,11 @@ interface PrefsModule {
@MeshLogSharedPreferences
fun provideMeshLogSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("meshlog-prefs", Context.MODE_PRIVATE)
@Provides
@Singleton
@FilterSharedPreferences
fun provideFilterSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences(FilterPrefs.FILTER_PREFS_NAME, Context.MODE_PRIVATE)
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.filter
import android.content.SharedPreferences
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.StringSetPrefDelegate
import org.meshtastic.core.prefs.di.FilterSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
/** Interface for managing message filter preferences. */
interface FilterPrefs {
/** Whether message filtering is enabled. */
var filterEnabled: Boolean
/** Set of words to filter messages on. */
var filterWords: Set<String>
companion object {
/** Key for the filterEnabled preference. */
const val KEY_FILTER_ENABLED = "filter_enabled"
/** Key for the filterWords preference. */
const val KEY_FILTER_WORDS = "filter_words"
/** Name of the SharedPreferences file where filter preferences are stored. */
const val FILTER_PREFS_NAME = "filter-prefs"
}
}
@Singleton
class FilterPrefsImpl @Inject constructor(@FilterSharedPreferences private val prefs: SharedPreferences) : FilterPrefs {
override var filterEnabled: Boolean by PrefDelegate(prefs, FilterPrefs.KEY_FILTER_ENABLED, false)
override var filterWords: Set<String> by StringSetPrefDelegate(prefs, FilterPrefs.KEY_FILTER_WORDS, emptySet())
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.filter
import android.content.SharedPreferences
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class FilterPrefsTest {
private lateinit var sharedPreferences: SharedPreferences
private lateinit var editor: SharedPreferences.Editor
private lateinit var filterPrefs: FilterPrefs
@Before
fun setup() {
editor = mockk(relaxed = true)
sharedPreferences = mockk {
every { getBoolean(FilterPrefs.KEY_FILTER_ENABLED, false) } returns false
every { getStringSet(FilterPrefs.KEY_FILTER_WORDS, emptySet()) } returns emptySet()
every { edit() } returns editor
}
filterPrefs = FilterPrefsImpl(sharedPreferences)
}
@Test
fun `filterEnabled defaults to false`() {
assertFalse(filterPrefs.filterEnabled)
}
@Test
fun `filterWords defaults to empty set`() {
assertTrue(filterPrefs.filterWords.isEmpty())
}
@Test
fun `setting filterEnabled updates preference`() {
filterPrefs.filterEnabled = true
verify { editor.putBoolean(FilterPrefs.KEY_FILTER_ENABLED, true) }
}
@Test
fun `setting filterWords updates preference`() {
val words = setOf("test", "word")
filterPrefs.filterWords = words
verify { editor.putStringSet(FilterPrefs.KEY_FILTER_WORDS, words) }
}
}

View file

@ -16,23 +16,6 @@
*/
import com.android.build.api.dsl.LibraryExtension
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins { alias(libs.plugins.meshtastic.android.library) }
configure<LibraryExtension> {
@ -45,6 +28,7 @@ configure<LibraryExtension> {
dependencies {
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
@ -52,4 +36,5 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service.filter
import co.touchlab.kermit.Logger
import org.meshtastic.core.prefs.filter.FilterPrefs
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Service for filtering messages based on user-configured filter words. Supports both plain text word matching and
* regex patterns.
*/
@Singleton
class MessageFilterService @Inject constructor(private val filterPrefs: FilterPrefs) {
private var compiledPatterns: List<Regex> = emptyList()
init {
rebuildPatterns()
}
/**
* Determines if a message should be filtered based on the configured filter words.
*
* @param message The message text to check.
* @param isFilteringDisabled Whether filtering is disabled for this contact.
* @return true if the message should be filtered, false otherwise.
*/
fun shouldFilter(message: String, isFilteringDisabled: Boolean = false): Boolean {
if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
return false
}
val textToCheck = message.take(MAX_CHECK_LENGTH)
return compiledPatterns.any { it.containsMatchIn(textToCheck) }
}
/**
* Rebuilds the compiled regex patterns from the current filter words. Should be called whenever the filter words
* are updated.
*/
fun rebuildPatterns() {
compiledPatterns =
filterPrefs.filterWords.mapNotNull { word ->
try {
if (word.startsWith(REGEX_PREFIX)) {
Regex(word.removePrefix(REGEX_PREFIX), RegexOption.IGNORE_CASE)
} else {
Regex("\\b${Regex.escape(word)}\\b", RegexOption.IGNORE_CASE)
}
} catch (e: PatternSyntaxException) {
Logger.w { "Invalid filter pattern: $word - ${e.message}" }
null
}
}
}
companion object {
private const val MAX_CHECK_LENGTH = 10_000
private const val REGEX_PREFIX = "regex:"
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service.filter
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.filter.FilterPrefs
class MessageFilterServiceTest {
private lateinit var filterPrefs: FilterPrefs
private lateinit var filterService: MessageFilterService
@Before
fun setup() {
filterPrefs = mockk {
every { filterEnabled } returns true
every { filterWords } returns setOf("spam", "bad")
}
filterService = MessageFilterService(filterPrefs)
}
@Test
fun `shouldFilter returns false when filter is disabled`() {
every { filterPrefs.filterEnabled } returns false
assertFalse(filterService.shouldFilter("spam message"))
}
@Test
fun `shouldFilter returns false when filter words is empty`() {
every { filterPrefs.filterWords } returns emptySet()
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
@Test
fun `shouldFilter returns true for exact word match`() {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is spam"))
}
@Test
fun `shouldFilter is case insensitive`() {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("This is SPAM"))
}
@Test
fun `shouldFilter matches whole words only`() {
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("antispam software"))
}
@Test
fun `shouldFilter supports regex patterns`() {
every { filterPrefs.filterWords } returns setOf("regex:test\\d+")
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is test123"))
assertFalse(filterService.shouldFilter("this is test"))
}
@Test
fun `shouldFilter handles invalid regex gracefully`() {
every { filterPrefs.filterWords } returns setOf("regex:[invalid")
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
@Test
fun `shouldFilter returns false when contact has filtering disabled`() {
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("spam message", isFilteringDisabled = true))
}
@Test
fun `shouldFilter filters when contact has filtering enabled`() {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
}
}

View file

@ -1143,4 +1143,21 @@
<string name="add_channels_description">The following channels were found in the QR code. Select the once you would like to add to your device. Existing channels will be preserved.</string>
<string name="replace_channels_and_settings_title">Replace Channels &amp; Settings</string>
<string name="replace_channels_and_settings_description">This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed.</string>
<!-- Message Filter -->
<string name="filter_settings">Message Filter</string>
<string name="filter_enable">Enable Filtering</string>
<string name="filter_enable_summary">Hide messages containing filter words</string>
<string name="filter_words">Filter Words</string>
<string name="filter_words_summary">Messages containing these words will be hidden</string>
<string name="filter_add_placeholder">Add word or regex:pattern</string>
<string name="filter_no_words">No filter words configured</string>
<string name="filter_regex_pattern">Regex pattern</string>
<string name="filter_whole_word">Whole word match</string>
<string name="filter_filtered_count">%1$d filtered</string>
<string name="filter_show_count">Show %1$d filtered</string>
<string name="filter_hide_count">Hide %1$d filtered</string>
<string name="filter_message_label">Filtered</string>
<string name="filter_enable_for_contact">Enable filtering</string>
<string name="filter_disable_for_contact">Disable filtering</string>
</resources>

View file

@ -57,6 +57,10 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.SpeakerNotes
import androidx.compose.material.icons.filled.SpeakerNotesOff
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.rounded.FilterList
import androidx.compose.material.icons.rounded.FilterListOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
@ -113,6 +117,10 @@ import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.delete_messages
import org.meshtastic.core.strings.delete_messages_title
import org.meshtastic.core.strings.filter_disable_for_contact
import org.meshtastic.core.strings.filter_enable_for_contact
import org.meshtastic.core.strings.filter_hide_count
import org.meshtastic.core.strings.filter_show_count
import org.meshtastic.core.strings.message_input_label
import org.meshtastic.core.strings.navigate_back
import org.meshtastic.core.strings.overflow_menu
@ -178,6 +186,9 @@ fun MessageScreen(
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val messageInputState = rememberTextFieldState(message)
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
val filteredCount by viewModel.getFilteredCount(contactKey).collectAsStateWithLifecycle(initialValue = 0)
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
// Retry dialog state
var currentRetryEvent by remember { mutableStateOf<RetryEvent?>(null) }
@ -381,6 +392,13 @@ fun MessageScreen(
showQuickChat = showQuickChat,
onToggleQuickChat = viewModel::toggleShowQuickChat,
onNavigateToQuickChatOptions = navigateToQuickChatOptions,
filteringDisabled = filteringDisabled,
onToggleFilteringDisabled = {
viewModel.setContactFilteringDisabled(contactKey, !filteringDisabled)
},
filteredCount = filteredCount,
showFiltered = showFiltered,
onToggleShowFiltered = viewModel::toggleShowFiltered,
)
}
},
@ -399,6 +417,9 @@ fun MessageScreen(
contactKey = contactKey,
firstUnreadMessageUuid = firstUnreadMessageUuid,
hasUnreadMessages = hasUnreadMessages,
filteredCount = filteredCount,
showFiltered = showFiltered,
filteringDisabled = filteringDisabled,
),
handlers =
MessageListHandlers(
@ -695,6 +716,11 @@ private fun MessageTopBar(
showQuickChat: Boolean,
onToggleQuickChat: () -> Unit,
onNavigateToQuickChatOptions: () -> Unit = {},
filteringDisabled: Boolean = false,
onToggleFilteringDisabled: () -> Unit = {},
filteredCount: Int = 0,
showFiltered: Boolean = false,
onToggleShowFiltered: () -> Unit = {},
) = TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -716,11 +742,16 @@ private fun MessageTopBar(
},
actions = {
MessageTopBarActions(
showQuickChat,
onToggleQuickChat,
onNavigateToQuickChatOptions,
channelIndex,
mismatchKey,
showQuickChat = showQuickChat,
onToggleQuickChat = onToggleQuickChat,
onNavigateToQuickChatOptions = onNavigateToQuickChatOptions,
channelIndex = channelIndex,
mismatchKey = mismatchKey,
filteringDisabled = filteringDisabled,
onToggleFilteringDisabled = onToggleFilteringDisabled,
filteredCount = filteredCount,
showFiltered = showFiltered,
onToggleShowFiltered = onToggleShowFiltered,
)
},
)
@ -732,6 +763,11 @@ private fun MessageTopBarActions(
onNavigateToQuickChatOptions: () -> Unit,
channelIndex: Int?,
mismatchKey: Boolean,
filteringDisabled: Boolean,
onToggleFilteringDisabled: () -> Unit,
filteredCount: Int,
showFiltered: Boolean,
onToggleShowFiltered: () -> Unit,
) {
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey)
@ -747,6 +783,11 @@ private fun MessageTopBarActions(
showQuickChat = showQuickChat,
onToggleQuickChat = onToggleQuickChat,
onNavigateToQuickChatOptions = onNavigateToQuickChatOptions,
filteringDisabled = filteringDisabled,
onToggleFilteringDisabled = onToggleFilteringDisabled,
filteredCount = filteredCount,
showFiltered = showFiltered,
onToggleShowFiltered = onToggleShowFiltered,
)
}
}
@ -758,50 +799,94 @@ private fun OverFlowMenu(
showQuickChat: Boolean,
onToggleQuickChat: () -> Unit,
onNavigateToQuickChatOptions: () -> Unit,
filteringDisabled: Boolean,
onToggleFilteringDisabled: () -> Unit,
filteredCount: Int,
showFiltered: Boolean,
onToggleShowFiltered: () -> Unit,
) {
if (expanded) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
val quickChatToggleTitle =
if (showQuickChat) {
stringResource(Res.string.quick_chat_hide)
} else {
stringResource(Res.string.quick_chat_show)
}
DropdownMenuItem(
text = { Text(quickChatToggleTitle) },
onClick = {
onDismiss()
onToggleQuickChat()
},
leadingIcon = {
Icon(
imageVector =
if (showQuickChat) {
Icons.Default.SpeakerNotesOff
} else {
Icons.Default.SpeakerNotes
},
contentDescription = quickChatToggleTitle,
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.quick_chat)) },
onClick = {
onDismiss()
onNavigateToQuickChatOptions()
},
leadingIcon = {
Icon(
imageVector = Icons.Default.ChatBubbleOutline,
contentDescription = stringResource(Res.string.quick_chat),
)
},
)
QuickChatToggleMenuItem(showQuickChat, onDismiss, onToggleQuickChat)
QuickChatOptionsMenuItem(onDismiss, onNavigateToQuickChatOptions)
if (filteredCount > 0 && !filteringDisabled) {
FilteredMessagesMenuItem(showFiltered, filteredCount, onDismiss, onToggleShowFiltered)
}
FilterToggleMenuItem(filteringDisabled, onDismiss, onToggleFilteringDisabled)
}
}
}
@Composable
private fun QuickChatToggleMenuItem(showQuickChat: Boolean, onDismiss: () -> Unit, onToggle: () -> Unit) {
val title = stringResource(if (showQuickChat) Res.string.quick_chat_hide else Res.string.quick_chat_show)
DropdownMenuItem(
text = { Text(title) },
onClick = {
onDismiss()
onToggle()
},
leadingIcon = {
Icon(
imageVector = if (showQuickChat) Icons.Default.SpeakerNotesOff else Icons.Default.SpeakerNotes,
contentDescription = title,
)
},
)
}
@Composable
private fun QuickChatOptionsMenuItem(onDismiss: () -> Unit, onNavigate: () -> Unit) {
val title = stringResource(Res.string.quick_chat)
DropdownMenuItem(
text = { Text(title) },
onClick = {
onDismiss()
onNavigate()
},
leadingIcon = { Icon(imageVector = Icons.Default.ChatBubbleOutline, contentDescription = title) },
)
}
@Composable
private fun FilteredMessagesMenuItem(showFiltered: Boolean, count: Int, onDismiss: () -> Unit, onToggle: () -> Unit) {
val title = stringResource(if (showFiltered) Res.string.filter_hide_count else Res.string.filter_show_count, count)
DropdownMenuItem(
text = { Text(title) },
onClick = {
onDismiss()
onToggle()
},
leadingIcon = {
Icon(
imageVector = if (showFiltered) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = title,
)
},
)
}
@Composable
private fun FilterToggleMenuItem(filteringDisabled: Boolean, onDismiss: () -> Unit, onToggle: () -> Unit) {
val title =
stringResource(
if (filteringDisabled) Res.string.filter_enable_for_contact else Res.string.filter_disable_for_contact,
)
DropdownMenuItem(
text = { Text(title) },
onClick = {
onDismiss()
onToggle()
},
leadingIcon = {
Icon(
imageVector = if (filteringDisabled) Icons.Rounded.FilterList else Icons.Rounded.FilterListOff,
contentDescription = title,
)
},
)
}
/**
* A row of quick chat action buttons.
*

View file

@ -87,6 +87,9 @@ internal data class MessageListPagedState(
val contactKey: String,
val firstUnreadMessageUuid: Long? = null,
val hasUnreadMessages: Boolean = false,
val filteredCount: Int = 0,
val showFiltered: Boolean = false,
val filteringDisabled: Boolean = false,
)
private fun MutableState<Set<Long>>.toggle(uuid: Long) {

View file

@ -29,6 +29,7 @@ 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.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
@ -82,6 +83,9 @@ constructor(
private val _showQuickChat = MutableStateFlow(uiPrefs.showQuickChat)
val showQuickChat: StateFlow<Boolean> = _showQuickChat
private val _showFiltered = MutableStateFlow(false)
val showFiltered: StateFlow<Boolean> = _showFiltered.asStateFlow()
val quickChatActions = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList())
val contactSettings: StateFlow<Map<String, ContactSettings>> =
@ -91,9 +95,19 @@ constructor(
private val contactKeyForPagedMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val pagedMessagesForContactKey: Flow<PagingData<Message>> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) }
combine(contactKeyForPagedMessages.filterNotNull(), _showFiltered, contactSettings) {
contactKey,
showFiltered,
settings,
->
// If filtering is disabled for this contact, always include filtered messages
val filteringDisabled = settings[contactKey]?.filteringDisabled ?: false
val includeFiltered = showFiltered || filteringDisabled
contactKey to includeFiltered
}
.flatMapLatest { (contactKey, includeFiltered) ->
packetRepository.getMessagesFromPaged(contactKey, includeFiltered, ::getNode)
}
.cachedIn(viewModelScope)
val frequentEmojis: List<String>
@ -133,6 +147,16 @@ constructor(
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
fun toggleShowFiltered() {
_showFiltered.update { !it }
}
fun getFilteredCount(contactKey: String): Flow<Int> = packetRepository.getFilteredCountFlow(contactKey)
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {
(!state.value).let { toggled ->
state.update { toggled }

View file

@ -70,6 +70,7 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.filter_message_label
import org.meshtastic.core.strings.hops_away_template
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
@ -174,7 +175,9 @@ internal fun MessageItem(
val containsBel = message.text.contains('\u0007')
val alpha =
if (inSelectionMode) {
if (message.filtered) {
FILTERED_ALPHA
} else if (inSelectionMode) {
if (selected) SELECTED_ALPHA else UNSELECTED_ALPHA
} else {
NORMAL_ALPHA
@ -284,6 +287,14 @@ internal fun MessageItem(
if (containsBel) {
Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
}
if (message.filtered) {
Text(
text = stringResource(Res.string.filter_message_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
modifier = Modifier.padding(8.dp),
@ -319,6 +330,7 @@ internal fun MessageItem(
private const val SELECTED_ALPHA = 0.6f
private const val UNSELECTED_ALPHA = 0.2f
private const val NORMAL_ALPHA = 0.4f
private const val FILTERED_ALPHA = 0.5f
private enum class ActiveSheet {
Actions,
@ -464,6 +476,26 @@ private fun MessageItemPreview() {
originalMessage = received,
viaMqtt = true,
)
val filteredMessage =
Message(
text = "This message was filtered",
time = "10:30",
fromLocal = false,
status = MessageStatus.RECEIVED,
snr = 1.5f,
rssi = 70,
hopsAway = 1,
uuid = 3L,
receivedTime = System.currentTimeMillis(),
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,
packetId = 4546,
emojis = listOf(),
replyId = null,
viaMqtt = false,
filtered = true,
)
AppTheme {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp)) {
MessageItem(
@ -510,6 +542,21 @@ private fun MessageItemPreview() {
onClickChip = {},
onNavigateToOriginalMessage = {},
)
MessageItem(
message = filteredMessage,
node = filteredMessage.node,
selected = false,
ourNode = sent.node,
onReply = {},
sendReaction = {},
onShowReactions = {},
onClick = {},
onLongClick = {},
onDoubleClick = {},
onClickChip = {},
onNavigateToOriginalMessage = {},
)
}
}
}

View file

@ -38,6 +38,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.FilterList
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
@ -348,6 +349,14 @@ fun SettingsScreen(
showThemePickerDialog = true
}
ListItem(
text = "Message Filter",
leadingIcon = Icons.Rounded.FilterList,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigate(SettingsRoutes.FilterSettings)
}
// Node DB cache limit (App setting)
val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
val cacheItems = remember {

View file

@ -0,0 +1,191 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.filter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.filter_add_placeholder
import org.meshtastic.core.strings.filter_enable
import org.meshtastic.core.strings.filter_enable_summary
import org.meshtastic.core.strings.filter_no_words
import org.meshtastic.core.strings.filter_regex_pattern
import org.meshtastic.core.strings.filter_settings
import org.meshtastic.core.strings.filter_whole_word
import org.meshtastic.core.strings.filter_words
import org.meshtastic.core.strings.filter_words_summary
import org.meshtastic.core.ui.component.MainAppBar
@Composable
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel = hiltViewModel(), onBack: () -> Unit) {
val filterEnabled by viewModel.filterEnabled.collectAsState()
val filterWords by viewModel.filterWords.collectAsState()
var newWord by remember { mutableStateOf("") }
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.filter_settings),
canNavigateUp = true,
onNavigateUp = onBack,
ourNode = null,
showNodeChip = false,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { FilterEnableCard(filterEnabled) { viewModel.setFilterEnabled(it) } }
item {
FilterWordsInputCard(
newWord = newWord,
onNewWordChange = { newWord = it },
onAddWord = {
viewModel.addFilterWord(newWord)
newWord = ""
},
)
}
if (filterWords.isEmpty()) {
item {
Text(
stringResource(Res.string.filter_no_words),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
items(filterWords, key = { it }) { word ->
FilterWordItem(word = word, onRemove = { viewModel.removeFilterWord(word) })
}
}
}
}
@Composable
private fun FilterEnableCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(Res.string.filter_enable), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(Res.string.filter_enable_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
}
}
}
@Composable
private fun FilterWordsInputCard(newWord: String, onNewWordChange: (String) -> Unit, onAddWord: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(stringResource(Res.string.filter_words), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(Res.string.filter_words_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newWord,
onValueChange = onNewWordChange,
modifier = Modifier.weight(1f),
label = { Text(stringResource(Res.string.filter_add_placeholder)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onAddWord() }),
)
IconButton(onClick = onAddWord) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.add))
}
}
}
}
}
@Composable
private fun FilterWordItem(word: String, onRemove: () -> Unit) {
val isRegex = word.startsWith("regex:")
val displayText = if (isRegex) word.removePrefix("regex:") else word
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = displayText, style = MaterialTheme.typography.bodyLarge)
Text(
text =
stringResource(if (isRegex) Res.string.filter_regex_pattern else Res.string.filter_whole_word),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onRemove) {
Icon(Icons.Default.Delete, contentDescription = stringResource(Res.string.delete))
}
}
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.filter
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.service.filter.MessageFilterService
import javax.inject.Inject
@HiltViewModel
class FilterSettingsViewModel
@Inject
constructor(
private val filterPrefs: FilterPrefs,
private val messageFilterService: MessageFilterService,
) : ViewModel() {
private val _filterEnabled = MutableStateFlow(filterPrefs.filterEnabled)
val filterEnabled: StateFlow<Boolean> = _filterEnabled.asStateFlow()
private val _filterWords = MutableStateFlow(filterPrefs.filterWords.toList().sorted())
val filterWords: StateFlow<List<String>> = _filterWords.asStateFlow()
fun setFilterEnabled(enabled: Boolean) {
filterPrefs.filterEnabled = enabled
_filterEnabled.value = enabled
}
fun addFilterWord(word: String) {
if (word.isBlank()) return
val trimmed = word.trim()
val current = filterPrefs.filterWords.toMutableSet()
if (current.add(trimmed)) {
filterPrefs.filterWords = current
_filterWords.value = current.toList().sorted()
messageFilterService.rebuildPatterns()
}
}
fun removeFilterWord(word: String) {
val current = filterPrefs.filterWords.toMutableSet()
if (current.remove(word)) {
filterPrefs.filterWords = current
_filterWords.value = current.toList().sorted()
messageFilterService.rebuildPatterns()
}
}
}