Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4a58ea1
chore: Add .worktrees/ to gitignore
mdecourcy Jan 15, 2026
cf00c81
feat(filter): add FilterPrefs interface and implementation
mdecourcy Jan 15, 2026
939cadb
feat(filter): register FilterPrefs in Hilt DI module
mdecourcy Jan 15, 2026
6892d50
feat(filter): add filtered column to Packet entity with migration
mdecourcy Jan 15, 2026
f18392c
feat(filter): add filteringDisabled column to ContactSettings entity
mdecourcy Jan 15, 2026
a2e51d5
feat(filter): add MessageFilterService with regex support
mdecourcy Jan 15, 2026
86384b8
feat(filter): add contact-level filter bypass to MessageFilterService
mdecourcy Jan 15, 2026
b642bbd
feat(filter): add filtered message queries and contact filtering to P…
mdecourcy Jan 15, 2026
02817ae
feat(filter): integrate MessageFilterService into MeshDataHandler
mdecourcy Jan 15, 2026
a723a67
feat(filter): add filter support methods to PacketRepository
mdecourcy Jan 15, 2026
b989991
feat(filter): add FilterSettingsScreen and ViewModel
mdecourcy Jan 15, 2026
3941fa8
feat(filter): add filter settings navigation and entry point
mdecourcy Jan 15, 2026
0d36e7c
feat(filter): add showFiltered toggle to MessageViewModel
mdecourcy Jan 15, 2026
cb0cc04
feat(filter): add filtered message count badge and toggle in chat UI
mdecourcy Jan 15, 2026
31531da
feat(filter): add filtered flag to Message model and visual indicator
mdecourcy Jan 15, 2026
5a175c8
feat(filter): add per-contact filter disable toggle
mdecourcy Jan 15, 2026
4eb476d
test(filter): add integration test for message filter feature
mdecourcy Jan 15, 2026
c9b48bf
feat(filter): add string resources for filter UI
mdecourcy Jan 15, 2026
462581a
chore: add missing clearMyNodeInfo to FakeNodeInfoWriteDataSource
mdecourcy Jan 15, 2026
4e6b7e8
fix(filter): add left padding to Filtered label for better spacing
mdecourcy Jan 15, 2026
e487024
feat(filter): add per-contact filtering toggle to chat menu
mdecourcy Jan 15, 2026
0e6baa4
fix(filter): respect per-contact filtering disabled state
mdecourcy Jan 15, 2026
e40a807
fix(filter): skip notifications entirely for filtered messages
mdecourcy Jan 16, 2026
44c032e
fix(filter): auto-mark filtered messages as read and hide from contac…
mdecourcy Jan 16, 2026
9996ef6
ui(filter): replace regex TextButton with Switch toggle
mdecourcy Jan 16, 2026
dc637f9
ui(filter): simplify regex entry to use regex: prefix only
mdecourcy Jan 16, 2026
4841c8f
ui(filter): move filtered toggle from overlay to overflow menu
mdecourcy Jan 16, 2026
620bd55
i18n(filter): move hardcoded strings to strings.xml for translation
mdecourcy Jan 16, 2026
8f08a7b
fix(filter): skip reaction notifications for filtered messages
mdecourcy Jan 16, 2026
4e0a6c6
test(filter): add database tests for filtered message queries
mdecourcy Jan 16, 2026
49bad4d
chore: update copyright to 2025-2026 in filter feature files
mdecourcy Jan 16, 2026
00b5667
fix(ci): specify JUnit XML file paths for Codecov test results upload
mdecourcy Jan 16, 2026
84903ef
fix: apply spotless formatting and suppress detekt complexity warning
mdecourcy Jan 16, 2026
ecad04a
refactor: extract shouldFilterMessage and handlePacketNotification to…
mdecourcy Jan 16, 2026
0ed8c30
fix: remove unused contactKey param and reduce return statements in s…
mdecourcy Jan 16, 2026
745ff6a
refactor: extract composables to reduce method length and fix duplica…
mdecourcy Jan 16, 2026
d7f0b0b
fix: correct database test to verify getContactKeys excludes filtered…
mdecourcy Jan 16, 2026
7843ae3
fix: add missing assertFalse import and explicit Unit return types in…
mdecourcy Jan 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/reusable-android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
directory: .
files: "**/build/test-results/**/TEST-*.xml"

- name: Upload F-Droid debug artifact
if: ${{ inputs.upload_artifacts }}
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/reusable-android-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
directory: .
files: "**/build/outputs/androidTest-results/**/TEST-*.xml"

- name: Upload Test Results
if: ${{ always() && inputs.upload_artifacts }}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ keystore.properties
# Personal build scripts
build-and-install-android.sh
wireless-install.sh

# Git worktrees
.worktrees/
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.kotlinx.coroutines.test)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.filter

import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.service.filter.MessageFilterService
import javax.inject.Inject

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MessageFilterIntegrationTest {

@get:Rule var hiltRule = HiltAndroidRule(this)

@Inject lateinit var filterPrefs: FilterPrefs

@Inject lateinit var filterService: MessageFilterService

@Before
fun setup() {
hiltRule.inject()
}

@Test
fun filterPrefsIntegration() = runTest {
filterPrefs.filterEnabled = true
filterPrefs.filterWords = setOf("test", "spam")
filterService.rebuildPatterns()

assertTrue(filterService.shouldFilter("this is a test message"))
assertTrue(filterService.shouldFilter("spam content"))
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

@file:Suppress("Wrapping", "SpacingAroundColon")

package com.geeksville.mesh.navigation
Expand All @@ -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
Expand Down Expand Up @@ -175,6 +175,8 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
}

composable<SettingsRoutes.About> { AboutScreen(onNavigateUp = navController::navigateUp) }

composable<SettingsRoutes.FilterSettings> { FilterSettingsScreen(onBack = navController::navigateUp) }
}
}

Expand Down
77 changes: 52 additions & 25 deletions app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,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.
Expand Down
4 changes: 2 additions & 2 deletions app/src/test/java/com/geeksville/mesh/service/Fakes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ class FakeNodeInfoWriteDataSource : NodeInfoWriteDataSource {

override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {}

override suspend fun clearNodeDB(preserveFavorites: Boolean) {}

override suspend fun clearMyNodeInfo() {}

override suspend fun clearNodeDB(preserveFavorites: Boolean) {}

override suspend fun deleteNode(num: Int) {}

override suspend fun deleteNodes(nodeNums: List<Int>) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,43 @@ constructor(
suspend fun findReactionsWithId(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(packetId) }

fun getFilteredCountFlow(contactKey: String): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(contactKey) }

suspend fun getFilteredCount(contactKey: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(contactKey) }

fun getMessagesFromPaged(
contactKey: String,
includeFiltered: Boolean,
getNode: suspend (String?) -> Node,
): Flow<PagingData<Message>> = Pager(
config =
PagingConfig(
pageSize = MESSAGES_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = MESSAGES_PAGE_SIZE,
),
pagingSourceFactory = {
dbManager.currentDb.value.packetDao().getMessagesFromPaged(contactKey, includeFiltered)
},
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}

suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().setContactFilteringDisabled(contactKey, disabled)
}

suspend fun clearPacketDB() = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() }

suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) =
Expand Down
Loading