From c0f8ed3503a6d632c9f2d93dea5fe4011614edf5 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Sat, 24 Jan 2026 08:41:17 -0800 Subject: [PATCH] feat: word-based message filtering with quarantine approach (stored but hidden) (#4241) --- .gitignore | 3 + app/build.gradle.kts | 2 + .../filter/MessageFilterIntegrationTest.kt | 56 + .../mesh/navigation/SettingsNavigation.kt | 6 +- .../mesh/service/MeshDataHandler.kt | 77 +- .../service/MeshServiceNotificationsImpl.kt | 2 +- .../mesh/ui/contact/ContactsViewModel.kt | 4 + .../java/com/geeksville/mesh/service/Fakes.kt | 4 +- .../core/data/repository/PacketRepository.kt | 72 +- .../33.json | 1011 +++++++++++++++++ .../core/database/dao/PacketDaoTest.kt | 124 ++ .../core/database/MeshtasticDatabase.kt | 3 +- .../meshtastic/core/database/dao/PacketDao.kt | 59 +- .../meshtastic/core/database/entity/Packet.kt | 3 + .../meshtastic/core/database/model/Message.kt | 1 + .../meshtastic/core/model/DataPacketTest.kt | 10 +- .../org/meshtastic/core/navigation/Routes.kt | 2 + core/prefs/build.gradle.kts | 7 +- .../meshtastic/core/prefs/di/PrefsModule.kt | 14 + .../core/prefs/filter/FilterPrefs.kt | 50 + .../core/prefs/filter/FilterPrefsTest.kt | 66 ++ core/service/build.gradle.kts | 19 +- .../service/filter/MessageFilterService.kt | 76 ++ .../filter/MessageFilterServiceTest.kt | 97 ++ .../composeResources/values/strings.xml | 17 + .../meshtastic/feature/messaging/Message.kt | 169 ++- .../feature/messaging/MessageListPaged.kt | 3 + .../feature/messaging/MessageViewModel.kt | 30 +- .../messaging/component/MessageItem.kt | 49 +- .../feature/settings/SettingsScreen.kt | 9 + .../settings/filter/FilterSettingsScreen.kt | 191 ++++ .../filter/FilterSettingsViewModel.kt | 66 ++ 32 files changed, 2187 insertions(+), 115 deletions(-) create mode 100644 app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/33.json create mode 100644 core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt create mode 100644 core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt create mode 100644 core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt create mode 100644 core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt diff --git a/.gitignore b/.gitignore index c3468500e..633b732fb 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ keystore.properties # Personal build scripts build-and-install-android.sh wireless-install.sh + +# Git worktrees +.worktrees/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4e555857..5c47e75b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt new file mode 100644 index 000000000..6a701aa8c --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt @@ -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 . + */ +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")) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt index e2371d05b..29ee0dc62 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt @@ -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 . */ - @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 { AboutScreen(onNavigateUp = navController::navigateUp) } + + composable { FilterSettingsScreen(onBack = navController::navigateUp) } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index 369ade227..944135710 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt index 3fd81570d..95e2fe6d4 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -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 { diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt index b1057558a..c98a02bd8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt @@ -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. diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt index 1b25abf66..3367840ae 100644 --- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt +++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt @@ -56,10 +56,10 @@ class FakeNodeInfoWriteDataSource : NodeInfoWriteDataSource { override suspend fun installConfig(mi: MyNodeEntity, nodes: List) {} - 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) {} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index 2764f63b7..0a80ae3ba 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -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> = 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 = + 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> = 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, newSettings: List) = diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/33.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/33.json new file mode 100644 index 000000000..29eb7b688 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/33.json @@ -0,0 +1,1011 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "39cc6bc0cf1dfe244ed72537dde19464", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '39cc6bc0cf1dfe244ed72537dde19464')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt index 093a9cd7c..861816b9b 100644 --- a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt +++ b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt @@ -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 } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 4aecd9c53..ee114be5a 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -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) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 122c34a4b..5a745464a 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -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> + @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> + @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 + + @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 + + @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() diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt index de32932a9..6b127d2d0 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -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 diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt index 94e9a287a..dd928e20b 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt @@ -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 { val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt index af57a6e76..19d071e51 100644 --- a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt @@ -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()) diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index b7a923c66..b2e2d7f42 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -151,6 +151,8 @@ object SettingsRoutes { @Serializable data object About : Route + @Serializable data object FilterSettings : Route + // endregion } diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index d6278a86f..e34c0daf2 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -40,4 +40,9 @@ plugins { configure { namespace = "org.meshtastic.core.prefs" } -dependencies { googleImplementation(libs.maps.compose) } +dependencies { + googleImplementation(libs.maps.compose) + + testImplementation(libs.junit) + testImplementation(libs.mockk) +} diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt index d6890f308..81d7d9b97 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/di/PrefsModule.kt @@ -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) } } diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt new file mode 100644 index 000000000..aa76cba8d --- /dev/null +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/filter/FilterPrefs.kt @@ -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 . + */ +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 + + 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 by StringSetPrefDelegate(prefs, FilterPrefs.KEY_FILTER_WORDS, emptySet()) +} diff --git a/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt b/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt new file mode 100644 index 000000000..37db3f2ef --- /dev/null +++ b/core/prefs/src/test/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt @@ -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 . + */ +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) } + } +} diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index ef5549710..309deb29d 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -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 . - */ - plugins { alias(libs.plugins.meshtastic.android.library) } configure { @@ -45,6 +28,7 @@ configure { 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) } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt new file mode 100644 index 000000000..bb8a773aa --- /dev/null +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt @@ -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 . + */ +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 = 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:" + } +} diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt b/core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt new file mode 100644 index 000000000..4d9960573 --- /dev/null +++ b/core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 8a0a4f4f8..24cc35987 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -1143,4 +1143,21 @@ 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. Replace Channels & Settings This QR code contains a complete configuration. This will REPLACE your existing channels and radio settings. All existing channels will be removed. + + + Message Filter + Enable Filtering + Hide messages containing filter words + Filter Words + Messages containing these words will be hidden + Add word or regex:pattern + No filter words configured + Regex pattern + Whole word match + %1$d filtered + Show %1$d filtered + Hide %1$d filtered + Filtered + Enable filtering + Disable filtering diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index c7898fcaf..42fff3573 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -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()) } 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(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. * diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index f6ea4ec2d..d69e53b3e 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -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>.toggle(uuid: Long) { diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index e7e6ea3c1..f9259219c 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -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 = _showQuickChat + private val _showFiltered = MutableStateFlow(false) + val showFiltered: StateFlow = _showFiltered.asStateFlow() + val quickChatActions = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) val contactSettings: StateFlow> = @@ -91,9 +95,19 @@ constructor( private val contactKeyForPagedMessages: MutableStateFlow = MutableStateFlow(null) private val pagedMessagesForContactKey: Flow> = - 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 @@ -133,6 +147,16 @@ constructor( fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it } + fun toggleShowFiltered() { + _showFiltered.update { !it } + } + + fun getFilteredCount(contactKey: String): Flow = packetRepository.getFilteredCountFlow(contactKey) + + fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } + } + private fun toggle(state: MutableStateFlow, onChanged: (newValue: Boolean) -> Unit) { (!state.value).let { toggled -> state.update { toggled } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 42aa238d4..0c12a7b19 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -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 = {}, + ) } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 58b4397fc..86c9bdde6 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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 { diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt new file mode 100644 index 000000000..09febc6c2 --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt @@ -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 . + */ +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)) + } + } + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt new file mode 100644 index 000000000..77c17699a --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt @@ -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 . + */ +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 = _filterEnabled.asStateFlow() + + private val _filterWords = MutableStateFlow(filterPrefs.filterWords.toList().sorted()) + val filterWords: StateFlow> = _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() + } + } +}