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