mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: word-based message filtering with quarantine approach (stored but hidden) (#4241)
This commit is contained in:
parent
ae65e64a37
commit
c0f8ed3503
32 changed files with 2187 additions and 115 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -45,3 +45,6 @@ keystore.properties
|
|||
# Personal build scripts
|
||||
build-and-install-android.sh
|
||||
wireless-install.sh
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>) {}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ object SettingsRoutes {
|
|||
|
||||
@Serializable data object About : Route
|
||||
|
||||
@Serializable data object FilterSettings : Route
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue