feat/decoupling (#4685)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-03 07:15:28 -06:00 committed by GitHub
parent 40244f8337
commit 2c49db8041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
254 changed files with 5132 additions and 2666 deletions

View file

@ -16,11 +16,14 @@
*/
package org.meshtastic.core.service
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.ClientNotification
import javax.inject.Inject
import javax.inject.Singleton
@ -30,7 +33,8 @@ import javax.inject.Singleton
class AndroidRadioControllerImpl
@Inject
constructor(
private val serviceRepository: ServiceRepository,
@ApplicationContext private val context: Context,
private val serviceRepository: AndroidServiceRepository,
private val nodeRepository: NodeRepository,
) : RadioController {
@ -65,6 +69,14 @@ constructor(
serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
}
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {
serviceRepository.meshService?.setConfig(config.encode())
}
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {
serviceRepository.meshService?.setChannel(channel.encode())
}
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {
serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode())
}
@ -125,6 +137,14 @@ constructor(
serviceRepository.meshService?.requestReboot(packetId, destNum)
}
override suspend fun rebootToDfu(nodeNum: Int) {
serviceRepository.meshService?.rebootToDfu(nodeNum)
}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
serviceRepository.meshService?.requestRebootOta(requestId, destNum, mode, hash)
}
override suspend fun shutdown(destNum: Int, packetId: Int) {
serviceRepository.meshService?.requestShutdown(packetId, destNum)
}
@ -141,6 +161,26 @@ constructor(
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
}
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {
serviceRepository.meshService?.requestPosition(destNum, currentPosition)
}
override suspend fun requestUserInfo(destNum: Int) {
serviceRepository.meshService?.requestUserInfo(destNum)
}
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
serviceRepository.meshService?.requestTraceroute(requestId, destNum)
}
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
serviceRepository.meshService?.requestTelemetry(requestId, destNum, typeValue)
}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
serviceRepository.meshService?.requestNeighborInfo(requestId, destNum)
}
override suspend fun beginEditSettings(destNum: Int) {
serviceRepository.meshService?.beginEditSettings(destNum)
}
@ -158,4 +198,14 @@ constructor(
override fun stopProvideLocation() {
serviceRepository.meshService?.stopProvideLocation()
}
override fun setDeviceAddress(address: String) {
serviceRepository.meshService?.setDeviceAddress(address)
// Ensure service is running/restarted to handle the new address
val intent =
android.content.Intent().apply {
setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService")
}
context.startForegroundService(intent)
}
}

View file

@ -19,33 +19,25 @@ package org.meshtastic.core.service
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import javax.inject.Inject
import javax.inject.Singleton
data class TracerouteResponse(
val message: String,
val destinationNodeNum: Int,
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
val logUuid: String? = null,
) {
val hasOverlay: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
open class ServiceRepository @Inject constructor() {
open class AndroidServiceRepository @Inject constructor() : ServiceRepository {
var meshService: IMeshService? = null
private set
@ -55,86 +47,86 @@ open class ServiceRepository @Inject constructor() {
// Connection state to our radio device
private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
open val connectionState: StateFlow<ConnectionState>
override val connectionState: StateFlow<ConnectionState>
get() = _connectionState
fun setConnectionState(connectionState: ConnectionState) {
override fun setConnectionState(connectionState: ConnectionState) {
_connectionState.value = connectionState
}
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
val clientNotification: StateFlow<ClientNotification?>
override val clientNotification: StateFlow<ClientNotification?>
get() = _clientNotification
fun setClientNotification(notification: ClientNotification?) {
override fun setClientNotification(notification: ClientNotification?) {
notification?.message?.let { Logger.w { it } }
_clientNotification.value = notification
}
fun clearClientNotification() {
override fun clearClientNotification() {
_clientNotification.value = null
}
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?>
override val errorMessage: StateFlow<String?>
get() = _errorMessage
fun setErrorMessage(text: String, severity: Severity = Severity.Error) {
override fun setErrorMessage(text: String, severity: Severity) {
Logger.log(severity, "ServiceRepository", null, text)
_errorMessage.value = text
}
fun clearErrorMessage() {
override fun clearErrorMessage() {
_errorMessage.value = null
}
private val _connectionProgress = MutableStateFlow<String?>(null)
val connectionProgress: StateFlow<String?>
override val connectionProgress: StateFlow<String?>
get() = _connectionProgress
fun setConnectionProgress(text: String) {
override fun setConnectionProgress(text: String) {
if (connectionState.value != ConnectionState.Connected) {
_connectionProgress.value = text
}
}
private val _meshPacketFlow = MutableSharedFlow<MeshPacket>(extraBufferCapacity = 64)
val meshPacketFlow: SharedFlow<MeshPacket>
override val meshPacketFlow: SharedFlow<MeshPacket>
get() = _meshPacketFlow
suspend fun emitMeshPacket(packet: MeshPacket) {
override suspend fun emitMeshPacket(packet: MeshPacket) {
_meshPacketFlow.emit(packet)
}
private val _tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
val tracerouteResponse: StateFlow<TracerouteResponse?>
override val tracerouteResponse: StateFlow<TracerouteResponse?>
get() = _tracerouteResponse
fun setTracerouteResponse(value: TracerouteResponse?) {
override fun setTracerouteResponse(value: TracerouteResponse?) {
_tracerouteResponse.value = value
}
fun clearTracerouteResponse() {
override fun clearTracerouteResponse() {
setTracerouteResponse(null)
}
private val _neighborInfoResponse = MutableStateFlow<String?>(null)
val neighborInfoResponse: StateFlow<String?>
override val neighborInfoResponse: StateFlow<String?>
get() = _neighborInfoResponse
fun setNeighborInfoResponse(value: String?) {
override fun setNeighborInfoResponse(value: String?) {
_neighborInfoResponse.value = value
}
fun clearNeighborInfoResponse() {
override fun clearNeighborInfoResponse() {
setNeighborInfoResponse(null)
}
private val _serviceAction = Channel<ServiceAction>()
val serviceAction = _serviceAction.receiveAsFlow()
override val serviceAction: Flow<ServiceAction> = _serviceAction.receiveAsFlow()
suspend fun onServiceAction(action: ServiceAction) {
override suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.send(action)
}
}

View file

@ -1,73 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.app.Notification
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
const val SERVICE_NOTIFY_ID = 101
@Suppress("TooManyFunctions")
interface MeshServiceNotifications {
fun clearNotifications()
fun initChannels()
fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification
suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean = false,
)
suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean = false,
)
suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean = false,
)
fun showAlertNotification(contactKey: String, name: String, alert: String)
fun showNewNodeSeenNotification(node: NodeEntity)
fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean)
fun showClientNotification(clientNotification: ClientNotification)
fun cancelMessageNotification(contactKey: String)
fun cancelLowBatteryNotification(node: NodeEntity)
fun clearClientNotification(notification: ClientNotification)
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import org.meshtastic.core.database.model.Node
import org.meshtastic.proto.SharedContact
sealed class ServiceAction {
data class GetDeviceMetadata(val destNum: Int) : ServiceAction()
data class Favorite(val node: Node) : ServiceAction()
data class Ignore(val node: Node) : ServiceAction()
data class Mute(val node: Node) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
data class ImportContact(val contact: SharedContact) : ServiceAction()
data class SendContact(val contact: SharedContact) : ServiceAction()
}

View file

@ -21,11 +21,18 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.AndroidRadioControllerImpl
import org.meshtastic.core.service.AndroidServiceRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
@Binds @Singleton
abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
@Binds @Singleton
abstract fun bindServiceRepository(impl: AndroidServiceRepository): ServiceRepository
}

View file

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

View file

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