diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 85b9d46ba9..e480374fd0 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -89,30 +89,18 @@ jobs: - name: Determine Tasks id: tasks run: | - TASKS="" - # Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources + FLAVOR="${{ matrix.flavor }}" + FLAVOR_CAP=$(echo $FLAVOR | awk '{print toupper(substr($0,1,1))substr($0,2)}') IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}') IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"') - if [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then - [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS spotlessCheck detekt " - [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testDebugUnitTest " - fi - - FLAVOR="${{ matrix.flavor }}" - if [ "$IS_FIRST_API" = "true" ]; then - if [ "$FLAVOR" = "google" ]; then - TASKS="$TASKS assembleGoogleDebug " - [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest " - elif [ "$FLAVOR" = "fdroid" ]; then - TASKS="$TASKS assembleFdroidDebug " - [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest " - fi - fi + # Matrix-specific tasks + TASKS="assemble${FLAVOR_CAP}Debug " + [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS lint${FLAVOR_CAP}Debug " + [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS test${FLAVOR_CAP}DebugUnitTest " # Instrumented Test Tasks if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then - [ "$IS_FIRST_FLAVOR" = "true" ] && TASKS="$TASKS connectedDebugAndroidTest " if [ "$FLAVOR" = "google" ]; then TASKS="$TASKS connectedGoogleDebugAndroidTest " elif [ "$FLAVOR" = "fdroid" ]; then @@ -120,20 +108,22 @@ jobs: fi fi - # Run coverage report if unit tests were executed - if [ "${{ inputs.run_unit_tests }}" = "true" ] && [ "$IS_FIRST_API" = "true" ]; then - if [ "$IS_FIRST_FLAVOR" = "true" ]; then - TASKS="$TASKS koverXmlReportDebug " - fi - if [ "$FLAVOR" = "google" ]; then - TASKS="$TASKS koverXmlReportGoogleDebug " - elif [ "$FLAVOR" = "fdroid" ]; then - TASKS="$TASKS koverXmlReportFdroidDebug " - fi + # Run coverage report for this flavor + if [ "${{ inputs.run_unit_tests }}" = "true" ]; then + TASKS="$TASKS koverXmlReport${FLAVOR_CAP}Debug " fi echo "tasks=$TASKS" >> $GITHUB_OUTPUT echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT + echo "is_first_flavor=$IS_FIRST_FLAVOR" >> $GITHUB_OUTPUT + + - name: Code Style & Static Analysis + if: steps.tasks.outputs.is_first_api == 'true' && steps.tasks.outputs.is_first_flavor == 'true' + run: ./gradlew spotlessCheck detekt -Pci=true + + - name: Shared Unit Tests + if: steps.tasks.outputs.is_first_api == 'true' && steps.tasks.outputs.is_first_flavor == 'true' && inputs.run_unit_tests == true + run: ./gradlew testDebugUnitTest koverXmlReportDebug -Pci=true --continue - name: Enable KVM group perms if: inputs.run_instrumented_tests == true @@ -142,7 +132,7 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Run Check (with Emulator) + - name: Run Flavor Check (with Emulator) if: inputs.run_instrumented_tests == true uses: reactivecircus/android-emulator-runner@v2 env: @@ -155,7 +145,7 @@ jobs: disable-animations: true script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan - - name: Run Check (no Emulator) + - name: Run Flavor Check (no Emulator) if: inputs.run_instrumented_tests == false env: VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} diff --git a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt index 6a701aa8c6..2c327a7af7 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt @@ -26,7 +26,7 @@ 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 org.meshtastic.core.repository.MessageFilter import javax.inject.Inject @HiltAndroidTest @@ -37,7 +37,7 @@ class MessageFilterIntegrationTest { @Inject lateinit var filterPrefs: FilterPrefs - @Inject lateinit var filterService: MessageFilterService + @Inject lateinit var filterService: MessageFilter @Before fun setup() { diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt index 5c546f4763..dd07d74e2a 100644 --- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt +++ b/app/src/main/java/com/geeksville/mesh/ApplicationModule.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,13 +14,17 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import com.geeksville.mesh.repository.radio.AndroidRadioInterfaceService +import com.geeksville.mesh.service.AndroidAppWidgetUpdater +import com.geeksville.mesh.service.AndroidMeshLocationManager +import com.geeksville.mesh.service.AndroidMeshWorkerManager import com.geeksville.mesh.service.MeshServiceNotificationsImpl +import com.geeksville.mesh.service.ServiceBroadcasts import dagger.Binds import dagger.Module import dagger.Provides @@ -28,7 +32,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.di.ProcessLifecycle -import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.repository.MeshServiceNotifications import javax.inject.Singleton @InstallIn(SingletonComponent::class) @@ -37,6 +41,20 @@ interface ApplicationModule { @Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications + @Binds + fun bindMeshLocationManager(impl: AndroidMeshLocationManager): org.meshtastic.core.repository.MeshLocationManager + + @Binds fun bindMeshWorkerManager(impl: AndroidMeshWorkerManager): org.meshtastic.core.repository.MeshWorkerManager + + @Binds fun bindAppWidgetUpdater(impl: AndroidAppWidgetUpdater): org.meshtastic.core.repository.AppWidgetUpdater + + @Binds + fun bindRadioInterfaceService( + impl: AndroidRadioInterfaceService, + ): org.meshtastic.core.repository.RadioInterfaceService + + @Binds fun bindServiceBroadcasts(impl: ServiceBroadcasts): org.meshtastic.core.repository.ServiceBroadcasts + companion object { @Provides @ProcessLifecycle fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get() diff --git a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt index ca4b141a50..74fcea5bf7 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt @@ -29,10 +29,10 @@ import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.launch import org.meshtastic.core.common.util.SequentialJob +import org.meshtastic.core.service.AndroidServiceRepository import org.meshtastic.core.service.BindFailedException import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.ServiceClient -import org.meshtastic.core.service.ServiceRepository import javax.inject.Inject /** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */ @@ -41,7 +41,7 @@ class MeshServiceClient @Inject constructor( @ActivityContext private val context: Context, - private val serviceRepository: ServiceRepository, + private val serviceRepository: AndroidServiceRepository, private val serviceSetupJob: SequentialJob, ) : ServiceClient(IMeshService.Stub::asInterface), DefaultLifecycleObserver { diff --git a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt index a6759dae6c..4b7a25c501 100644 --- a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt +++ b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt @@ -22,18 +22,18 @@ import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.model.getMeshtasticShortName import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString -import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.usb.UsbRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.jetbrains.compose.resources.getString import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.DatabaseManager -import org.meshtastic.core.database.model.Node import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.meshtastic import java.util.Locale diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt index 6d2e4c448d..d66d6fff0e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt @@ -17,14 +17,14 @@ package com.geeksville.mesh.model import android.hardware.usb.UsbManager -import com.geeksville.mesh.repository.radio.InterfaceId -import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.hoho.android.usbserial.driver.UsbSerialDriver import no.nordicsemi.kotlin.ble.client.android.Peripheral import no.nordicsemi.kotlin.ble.core.BondState import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.anonymize +import org.meshtastic.core.repository.RadioInterfaceService /** * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is diff --git a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt index 52ef78ce5e..a3511ca74a 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt @@ -22,8 +22,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavHostController import co.touchlab.kermit.Logger -import com.geeksville.mesh.repository.radio.MeshActivity -import com.geeksville.mesh.repository.radio.RadioInterfaceService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow @@ -45,21 +43,24 @@ import org.jetbrains.compose.resources.getString import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.data.repository.FirmwareReleaseRepository import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.model.MeshActivity +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.model.evaluateTracerouteMapAvailability +import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.model.util.dispatchMeshtasticUri +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.client_notification import org.meshtastic.core.resources.compromised_keys +import org.meshtastic.core.service.AndroidServiceRepository import org.meshtastic.core.service.IMeshService -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.core.service.TracerouteResponse import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.ComposableContent @@ -75,7 +76,8 @@ class UIViewModel @Inject constructor( private val nodeDB: NodeRepository, - private val serviceRepository: ServiceRepository, + private val serviceRepository: AndroidServiceRepository, + private val radioController: RadioController, radioInterfaceService: RadioInterfaceService, meshLogRepository: MeshLogRepository, firmwareReleaseRepository: FirmwareReleaseRepository, @@ -161,6 +163,10 @@ constructor( val meshService: IMeshService? get() = serviceRepository.meshService + fun setDeviceAddress(address: String) { + radioController.setDeviceAddress(address) + } + val unreadMessageCount = packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0) @@ -172,7 +178,7 @@ constructor( } // hardware info about our local device (can be null) - val myNodeInfo: StateFlow + val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo init { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt similarity index 87% rename from app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt rename to app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt index f7cf8fbd57..cd190ad455 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt @@ -49,8 +49,11 @@ import org.meshtastic.core.common.util.toRemoteExceptions import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.di.ProcessLifecycle import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.prefs.radio.RadioPrefs +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.ToRadio import javax.inject.Inject @@ -65,9 +68,9 @@ import javax.inject.Singleton * Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it * can be stubbed out with a simulated version as needed. */ -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @Singleton -open class RadioInterfaceService +class AndroidRadioInterfaceService @Inject constructor( private val context: Application, @@ -78,20 +81,20 @@ constructor( private val radioPrefs: RadioPrefs, private val interfaceFactory: InterfaceFactory, private val analytics: PlatformAnalytics, -) { +) : RadioInterfaceService { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState: StateFlow = _connectionState.asStateFlow() + override val connectionState: StateFlow = _connectionState.asStateFlow() private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64) - val receivedData: SharedFlow = _receivedData + override val receivedData: SharedFlow = _receivedData private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64) val connectionError: SharedFlow = _connectionError.asSharedFlow() // Thread-safe StateFlow for tracking device address changes private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr) - val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() + override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() private val logSends = false private val logReceives = false @@ -100,8 +103,11 @@ constructor( val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") } + override val serviceScope: CoroutineScope + get() = _serviceScope + /** We recreate this scope each time we stop an interface */ - var serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) + private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) private var radioIf: IRadioInterface = NopInterface("") @@ -165,10 +171,10 @@ constructor( } /** Constructs a full radio address for the specific interface type. */ - fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = + override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = interfaceFactory.toInterfaceAddress(interfaceId, rest) - fun isMockInterface(): Boolean = + override fun isMockInterface(): Boolean = BuildConfig.DEBUG || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true" /** @@ -185,7 +191,7 @@ constructor( * where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device * path) */ - fun getDeviceAddress(): String? { + override fun getDeviceAddress(): String? { // If the user has unpaired our device, treat things as if we don't have one var address = radioPrefs.devAddr @@ -228,10 +234,11 @@ constructor( } // Handle an incoming packet from the radio, broadcasts it as an android intent - open fun handleFromRadio(p: ByteArray) { + @Suppress("TooGenericExceptionCaught") + override fun handleFromRadio(bytes: ByteArray) { if (logReceives) { try { - receivedPacketsLog.write(p) + receivedPacketsLog.write(bytes) receivedPacketsLog.flush() } catch (t: Throwable) { Logger.w(t) { "Failed to write receive log in handleFromRadio" } @@ -239,29 +246,33 @@ constructor( } try { - processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) } + processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) } emitReceiveActivity() } catch (t: Throwable) { Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" } } } - fun onConnect() { + override fun onConnect() { if (_connectionState.value != ConnectionState.Connected) { broadcastConnectionChanged(ConnectionState.Connected) } } - fun onDisconnect(isPermanent: Boolean) { + override fun onDisconnect(isPermanent: Boolean) { val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep if (_connectionState.value != newTargetState) { broadcastConnectionChanged(newTargetState) } } - fun onDisconnect(error: BleError) { - processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) } - onDisconnect(!error.shouldReconnect) + override fun onDisconnect(error: Any) { + if (error is BleError) { + processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) } + onDisconnect(!error.shouldReconnect) + } else { + onDisconnect(isPermanent = true) + } } /** Start our configured interface (if it isn't already running) */ @@ -311,8 +322,8 @@ constructor( r.close() // cancel any old jobs and get ready for the new ones - serviceScope.cancel("stopping interface") - serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) + _serviceScope.cancel("stopping interface") + _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) if (logSends) { sentPacketsLog.close() @@ -356,26 +367,28 @@ constructor( true } - fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { setBondedDeviceAddress(deviceAddr) } + override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { + setBondedDeviceAddress(deviceAddr) + } /** * If the service is not currently connected to the radio, try to connect now. At boot the radio interface service * will not connect to a radio until this call is received. */ - fun connect() = toRemoteExceptions { + override fun connect() = toRemoteExceptions { // We don't start actually talking to our device until MeshService binds to us - this prevents // broadcasting connection events before MeshService is ready to receive them startInterface() initStateListeners() } - fun sendToRadio(a: ByteArray) { + override fun sendToRadio(bytes: ByteArray) { // Do this in the IO thread because it might take a while (and we don't care about the result code) - serviceScope.handledLaunch { handleSendToRadio(a) } + _serviceScope.handledLaunch { handleSendToRadio(bytes) } } private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64) - val meshActivity: SharedFlow = _meshActivity.asSharedFlow() + override val meshActivity: SharedFlow = _meshActivity.asSharedFlow() private fun emitSendActivity() { // Use tryEmit for SharedFlow as it's non-blocking @@ -392,9 +405,3 @@ constructor( } } } - -sealed class MeshActivity { - data object Send : MeshActivity() - - data object Receive : MeshActivity() -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt index ffb34c2a8f..f511cb5554 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.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,41 +14,37 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.repository.radio +import org.meshtastic.core.model.InterfaceId import javax.inject.Inject import javax.inject.Provider /** * Entry point for create radio backend instances given a specific address. * - * This class is responsible for building and dissecting radio addresses based upon - * their interface type and the "rest" of the address (which varies per implementation). + * This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest" + * of the address (which varies per implementation). */ -class InterfaceFactory @Inject constructor( +class InterfaceFactory +@Inject +constructor( private val nopInterfaceFactory: NopInterfaceFactory, - private val specMap: Map>> + private val specMap: Map>>, ) { - internal val nopInterface by lazy { - nopInterfaceFactory.create("") - } + internal val nopInterface by lazy { nopInterfaceFactory.create("") } - fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String { - return "${interfaceId.id}$rest" - } + fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" fun createInterface(address: String): IRadioInterface { val (spec, rest) = splitAddress(address) return spec?.createInterface(rest) ?: nopInterface } - fun addressValid(address: String?): Boolean { - return address?.let { - val (spec, rest) = splitAddress(it) - spec?.addressValid(rest) - } ?: false - } + fun addressValid(address: String?): Boolean = address?.let { + val (spec, rest) = splitAddress(it) + spec?.addressValid(rest) + } ?: false private fun splitAddress(address: String): Pair?, String> { val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it]?.get() } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt index d6d6ae2ea8..fc9170c6af 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.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,14 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.repository.radio import dagger.MapKey +import org.meshtastic.core.model.InterfaceId -/** - * Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key. - */ +/** Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key. */ @MapKey @Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.PROPERTY_GETTER) @Retention(AnnotationRetention.RUNTIME) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt index 5b67d694f4..2dc509ed28 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Channel import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.getInitials +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Data diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index 19e0471390..aa72dfdd45 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -18,7 +18,6 @@ package com.geeksville.mesh.repository.radio import android.annotation.SuppressLint import co.touchlab.kermit.Logger -import com.geeksville.mesh.service.RadioNotConnectedException import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CompletableDeferred @@ -58,6 +57,8 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC import org.meshtastic.core.ble.retryBleOperation import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.RadioNotConnectedException +import org.meshtastic.core.repository.RadioInterfaceService import kotlin.time.Duration.Companion.seconds private const val SCAN_RETRY_COUNT = 3 @@ -95,7 +96,7 @@ constructor( Logger.w(e) { "[$address] Failed to disconnect in exception handler" } } } - service.onDisconnect(BleError.from(throwable)) + service.onDisconnect(error = BleError.from(throwable)) } private val connectionScope: CoroutineScope = @@ -152,7 +153,7 @@ constructor( "Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)" } try { - service.handleFromRadio(p = packet) + service.handleFromRadio(packet) } catch (t: Throwable) { Logger.e(t) { "[$address] Failed to execute service.handleFromRadio()" } } @@ -256,7 +257,7 @@ constructor( "Packets RX: $packetsReceived ($bytesReceived bytes), " + "Packets TX: $packetsSent ($bytesSent bytes)" } - service.onDisconnect(BleError.Disconnected(reason = state.reason)) + service.onDisconnect(error = BleError.Disconnected(reason = state.reason)) } private suspend fun discoverServicesAndSetupCharacteristics() { @@ -286,12 +287,12 @@ constructor( service.onConnect() } else { Logger.w { "[$address] Discovery failed: missing required characteristics" } - service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found")) + service.onDisconnect(error = BleError.DiscoveryFailed("One or more characteristics not found")) } } catch (e: Exception) { Logger.w(e) { "[$address] Service discovery failed" } bleConnection.disconnect() - service.onDisconnect(BleError.from(e)) + service.onDisconnect(error = BleError.from(e)) } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt index 49f989452b..112d38e290 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt @@ -31,13 +31,10 @@ constructor( override fun createInterface(rest: String): NordicBleInterface = factory.create(rest) /** Return true if this address is still acceptable. For BLE that means, still bonded */ - override fun addressValid(rest: String): Boolean { - val allPaired = bluetoothRepository.state.value.bondedDevices.map { it.address }.toSet() - return if (!allPaired.contains(rest)) { - Logger.w { "Ignoring stale bond to ${rest.anonymize}" } - false - } else { - true - } + override fun addressValid(rest: String): Boolean = if (!bluetoothRepository.isBonded(rest)) { + Logger.w { "Ignoring stale bond to ${rest.anonymize}" } + false + } else { + true } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt index 6a1d91f1a4..88d9579170 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.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 . */ - package com.geeksville.mesh.repository.radio import dagger.Binds @@ -23,6 +22,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoMap import dagger.multibindings.Multibinds +import org.meshtastic.core.model.InterfaceId @Suppress("unused") // Used by hilt @Module diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt index 4ebaf85d57..04d67b879f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt @@ -23,6 +23,7 @@ import com.geeksville.mesh.repository.usb.UsbRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.repository.RadioInterfaceService import java.util.concurrent.atomic.AtomicReference /** An interface that assumes we are talking to a meshtastic device via USB serial */ diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt index 538f4088a4..973c388381 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt @@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.meshtastic.core.repository.RadioInterfaceService /** * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index e2eeefa4c6..a6a8320a51 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.Exceptions import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.ToRadio import java.io.BufferedInputStream diff --git a/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt new file mode 100644 index 0000000000..9735b0ab5c --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt @@ -0,0 +1,40 @@ +/* + * 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.service + +import android.content.Context +import androidx.glance.appwidget.updateAll +import com.geeksville.mesh.widget.LocalStatsWidget +import dagger.hilt.android.qualifiers.ApplicationContext +import org.meshtastic.core.repository.AppWidgetUpdater +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AndroidAppWidgetUpdater @Inject constructor(@ApplicationContext private val context: Context) : AppWidgetUpdater { + override suspend fun updateAll() { + // Kickstart the widget composition. + // The widget internally uses collectAsState() and its own sampled StateFlow + // to drive updates automatically without excessive IPC and recreation. + @Suppress("TooGenericExceptionCaught") + try { + LocalStatsWidget().updateAll(context) + } catch (e: Exception) { + co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt similarity index 93% rename from app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt rename to app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt index 482424a5e6..7ab35c1519 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt @@ -29,23 +29,24 @@ import kotlinx.coroutines.flow.onEach import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.data.repository.LocationRepository import org.meshtastic.core.model.Position +import org.meshtastic.core.repository.MeshLocationManager import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds import org.meshtastic.proto.Position as ProtoPosition @Singleton -class MeshLocationManager +class AndroidMeshLocationManager @Inject constructor( private val context: Application, private val locationRepository: LocationRepository, -) { +) : MeshLocationManager { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var locationFlow: Job? = null @SuppressLint("MissingPermission") - fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) { + override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) { this.scope = scope if (locationFlow?.isActive == true) return @@ -76,7 +77,7 @@ constructor( } } - fun stop() { + override fun stop() { if (locationFlow?.isActive == true) { Logger.i { "Stopping location requests" } locationFlow?.cancel() diff --git a/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt new file mode 100644 index 0000000000..8b235ea5ca --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt @@ -0,0 +1,42 @@ +/* + * 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.service + +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import org.meshtastic.core.repository.MeshWorkerManager +import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AndroidMeshWorkerManager @Inject constructor(private val workManager: WorkManager) : MeshWorkerManager { + override fun enqueueSendMessage(packetId: Int) { + val workRequest = + OneTimeWorkRequestBuilder() + .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId)) + .build() + + workManager.enqueueUniqueWork( + "${SendMessageWorker.WORK_NAME_PREFIX}$packetId", + ExistingWorkPolicy.REPLACE, + workRequest, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt index 3f1a85ec35..23f6b17379 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt @@ -25,32 +25,34 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.PacketRepository import javax.inject.Inject /** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */ @AndroidEntryPoint class MarkAsReadReceiver : BroadcastReceiver() { + @Inject lateinit var packetRepository: PacketRepository - @Inject lateinit var meshServiceNotifications: MeshServiceNotifications + @Inject lateinit var serviceNotifications: MeshServiceNotifications private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) companion object { - const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ_ACTION" - const val CONTACT_KEY = "contactKey" + const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ" + const val CONTACT_KEY = "contact_key" } override fun onReceive(context: Context, intent: Intent) { if (intent.action == MARK_AS_READ_ACTION) { val contactKey = intent.getStringExtra(CONTACT_KEY) ?: return val pendingResult = goAsync() + scope.launch { try { packetRepository.clearUnreadCount(contactKey, nowMillis) - meshServiceNotifications.cancelMessageNotification(contactKey) + serviceNotifications.cancelMessageNotification(contactKey) } finally { pendingResult.finish() } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt deleted file mode 100644 index 1f284c7a7e..0000000000 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt +++ /dev/null @@ -1,269 +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 . - */ -package com.geeksville.mesh.service - -import androidx.annotation.VisibleForTesting -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import okio.ByteString -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.entity.MetadataEntity -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.util.NodeIdLookup -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.StatusMessage -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton -import org.meshtastic.proto.NodeInfo as ProtoNodeInfo -import org.meshtastic.proto.Position as ProtoPosition - -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") -@Singleton -class MeshNodeManager -@Inject -constructor( - private val nodeRepository: NodeRepository?, - private val serviceBroadcasts: MeshServiceBroadcasts?, - private val serviceNotifications: MeshServiceNotifications?, -) : NodeIdLookup { - private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - val nodeDBbyNodeNum = ConcurrentHashMap() - val nodeDBbyID = ConcurrentHashMap() - - fun start(scope: CoroutineScope) { - this.scope = scope - } - - val isNodeDbReady = MutableStateFlow(false) - val allowNodeDbWrites = MutableStateFlow(false) - - var myNodeNum: Int? = null - - companion object { - private const val TIME_MS_TO_S = 1000L - } - - @VisibleForTesting internal constructor() : this(null, null, null) - - fun loadCachedNodeDB() { - scope.handledLaunch { - val nodes = nodeRepository?.getNodeEntityDBbyNumFlow()?.first() ?: emptyMap() - nodeDBbyNodeNum.putAll(nodes) - nodes.values.forEach { nodeDBbyID[it.user.id] = it } - myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum - } - } - - fun clear() { - nodeDBbyNodeNum.clear() - nodeDBbyID.clear() - isNodeDbReady.value = false - allowNodeDbWrites.value = false - myNodeNum = null - } - - fun getMyNodeInfo(): MyNodeInfo? { - val mi = nodeRepository?.myNodeInfo?.value ?: return null - val myNode = nodeDBbyNodeNum[mi.myNodeNum] - return MyNodeInfo( - myNodeNum = mi.myNodeNum, - hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, - model = mi.model ?: myNode?.user?.hw_model?.name, - firmwareVersion = mi.firmwareVersion, - couldUpdate = mi.couldUpdate, - shouldUpdate = mi.shouldUpdate, - currentPacketId = mi.currentPacketId, - messageTimeoutMsec = mi.messageTimeoutMsec, - minAppVersion = mi.minAppVersion, - maxChannels = mi.maxChannels, - hasWifi = mi.hasWifi, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = mi.deviceId ?: myNode?.user?.id, - ) - } - - fun getMyId(): String { - val num = myNodeNum ?: nodeRepository?.myNodeInfo?.value?.myNodeNum ?: return "" - return nodeDBbyNodeNum[num]?.user?.id ?: "" - } - - fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() } - - fun removeByNodenum(nodeNum: Int) { - nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) } - } - - fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) { - val userId = DataPacket.nodeNumToDefaultId(n) - val defaultUser = - User( - id = userId, - long_name = "Meshtastic ${userId.takeLast(n = 4)}", - short_name = userId.takeLast(n = 4), - hw_model = HardwareModel.UNSET, - ) - - NodeEntity( - num = n, - user = defaultUser, - longName = defaultUser.long_name, - shortName = defaultUser.short_name, - channel = channel, - ) - } - - fun updateNodeInfo(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, updateFn: (NodeEntity) -> Unit) { - val info = getOrCreateNodeInfo(nodeNum, channel) - updateFn(info) - if (info.user.id.isNotEmpty()) { - nodeDBbyID[info.user.id] = info - } - - if (info.user.id.isNotEmpty() && isNodeDbReady.value) { - scope.handledLaunch { nodeRepository?.upsert(info) } - } - - if (withBroadcast) { - serviceBroadcasts?.broadcastNodeChange(info.toNodeInfo()) - } - } - - fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { - scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) } - } - - fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) { - updateNodeInfo(fromNum) { - val newNode = (it.isUnknownUser && p.hw_model != HardwareModel.UNSET) - val shouldPreserve = shouldPreserveExistingUser(it.user, p) - - if (shouldPreserve) { - it.longName = it.user.long_name - it.shortName = it.user.short_name - it.channel = channel - it.manuallyVerified = manuallyVerified - } else { - val keyMatch = !it.hasPKC || it.user.public_key == p.public_key - it.user = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) - it.longName = p.long_name - it.shortName = p.short_name - it.channel = channel - it.manuallyVerified = manuallyVerified - if (newNode) { - serviceNotifications?.showNewNodeSeenNotification(it) - } - } - } - } - - fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long = nowMillis) { - if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) { - Logger.d { "Ignoring nop position update for the local node" } - } else { - updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) } - } - } - - fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { - updateNodeInfo(fromNum) { nodeEntity -> - when { - telemetry.device_metrics != null -> nodeEntity.deviceTelemetry = telemetry - telemetry.environment_metrics != null -> nodeEntity.environmentTelemetry = telemetry - telemetry.power_metrics != null -> nodeEntity.powerTelemetry = telemetry - } - } - } - - fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) { - updateNodeInfo(fromNum) { it.paxcounter = p } - } - - fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) { - updateNodeStatus(fromNum, s.status) - } - - fun updateNodeStatus(nodeNum: Int, status: String?) { - updateNodeInfo(nodeNum) { it.nodeStatus = status?.takeIf { s -> s.isNotEmpty() } } - } - - fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) { - updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity -> - val user = info.user - if (user != null) { - if (shouldPreserveExistingUser(entity.user, user)) { - entity.longName = entity.user.long_name - entity.shortName = entity.user.short_name - } else { - var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it } - if (info.via_mqtt) { - newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") - } - entity.user = newUser - entity.longName = newUser.long_name - entity.shortName = newUser.short_name - } - } - val position = info.position - if (position != null) { - entity.position = position - entity.latitude = Position.degD(position.latitude_i ?: 0) - entity.longitude = Position.degD(position.longitude_i ?: 0) - } - entity.lastHeard = info.last_heard - if (info.device_metrics != null) { - entity.deviceTelemetry = Telemetry(device_metrics = info.device_metrics) - } - entity.channel = info.channel - entity.viaMqtt = info.via_mqtt - entity.hopsAway = info.hops_away ?: -1 - entity.isFavorite = info.is_favorite - entity.isIgnored = info.is_ignored - entity.isMuted = info.is_muted - } - } - - private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { - val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) - val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET - val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET - return hasExistingUser && isDefaultName && isDefaultHwModel - } - - override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt deleted file mode 100644 index b61bb6e02e..0000000000 --- a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt +++ /dev/null @@ -1,48 +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 . - */ -package com.geeksville.mesh.service - -import kotlinx.coroutines.CoroutineScope -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Orchestrates the specialized packet handlers for the [MeshService]. This class serves as a central registry and - * lifecycle manager for all routing sub-components. - */ -@Suppress("LongParameterList") -@Singleton -class MeshRouter -@Inject -constructor( - val dataHandler: MeshDataHandler, - val configHandler: MeshConfigHandler, - val tracerouteHandler: MeshTracerouteHandler, - val neighborInfoHandler: MeshNeighborInfoHandler, - val configFlowManager: MeshConfigFlowManager, - val mqttManager: MeshMqttManager, - val actionHandler: MeshActionHandler, -) { - fun start(scope: CoroutineScope) { - dataHandler.start(scope) - configHandler.start(scope) - tracerouteHandler.start(scope) - neighborInfoHandler.start(scope) - configFlowManager.start(scope) - actionHandler.start(scope) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 2f01f33680..cf97cd5c24 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -25,7 +25,6 @@ import android.os.IBinder import androidx.core.app.ServiceCompat import co.touchlab.kermit.Logger import com.geeksville.mesh.BuildConfig -import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -36,17 +35,27 @@ import kotlinx.coroutines.flow.onEach import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.toRemoteExceptions -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.Position +import org.meshtastic.core.model.RadioNotConnectedException +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.SERVICE_NOTIFY_ID +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.service.IMeshService -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.SERVICE_NOTIFY_ID -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.proto.PortNum import javax.inject.Inject @@ -58,17 +67,15 @@ class MeshService : Service() { @Inject lateinit var serviceRepository: ServiceRepository - @Inject lateinit var connectionStateHolder: ConnectionStateHandler - @Inject lateinit var packetHandler: PacketHandler - @Inject lateinit var serviceBroadcasts: MeshServiceBroadcasts + @Inject lateinit var serviceBroadcasts: ServiceBroadcasts - @Inject lateinit var nodeManager: MeshNodeManager + @Inject lateinit var nodeManager: NodeManager @Inject lateinit var messageProcessor: MeshMessageProcessor - @Inject lateinit var commandSender: MeshCommandSender + @Inject lateinit var commandSender: CommandSender @Inject lateinit var locationManager: MeshLocationManager @@ -90,7 +97,7 @@ class MeshService : Service() { fun actionReceived(portNum: Int): String { val portType = PortNum.fromValue(portNum) val portStr = portType?.toString() ?: portNum.toString() - return com.geeksville.mesh.service.actionReceived(portStr) + return actionReceived(portStr) } fun createIntent(context: Context) = Intent(context, MeshService::class.java) @@ -143,7 +150,7 @@ class MeshService : Service() { val a = radioInterfaceService.getDeviceAddress() val wantForeground = a != null && a != NO_DEVICE_SELECTED - val notification = connectionManager.updateStatusNotification() + val notification = connectionManager.updateStatusNotification() as android.app.Notification val foregroundServiceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -311,7 +318,7 @@ class MeshService : Service() { override fun getNodes(): List = nodeManager.getNodes() - override fun connectionState(): String = connectionStateHolder.connectionState.value.toString() + override fun connectionState(): String = serviceRepository.connectionState.value.toString() override fun startProvideLocation() { locationManager.start(serviceScope) { commandSender.sendPosition(it) } 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 babdc55650..47b0a7fb28 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -47,13 +47,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.model.Message import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Message +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.SERVICE_NOTIFY_ID import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.client_notification import org.meshtastic.core.resources.getString @@ -86,8 +88,6 @@ import org.meshtastic.core.resources.no_local_stats import org.meshtastic.core.resources.powered import org.meshtastic.core.resources.reply import org.meshtastic.core.resources.you -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.SERVICE_NOTIFY_ID import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.LocalStats @@ -309,16 +309,14 @@ constructor( if (myNodeNum != null) { // We use runBlocking here because this is called from MeshConnectionManager's synchronous methods, // and we only do this once if the cache is empty. - val nodes = runBlocking { repo.getNodeEntityDBbyNumFlow().first() } - nodes[myNodeNum]?.let { entity -> + val nodes = runBlocking { repo.nodeDBbyNum.first() } + nodes[myNodeNum]?.let { node -> if (cachedDeviceMetrics == null) { - cachedDeviceMetrics = entity.deviceTelemetry.device_metrics + cachedDeviceMetrics = node.deviceMetrics } if (cachedLocalStats == null) { // Fallback to DB stats if repository hasn't received any fresh ones yet - cachedLocalStats = - repo.localStats.value.takeIf { it.uptime_seconds != 0 } - ?: entity.deviceTelemetry.local_stats + cachedLocalStats = repo.localStats.value.takeIf { it.uptime_seconds != 0 } } } } @@ -477,12 +475,12 @@ constructor( notificationManager.notify(name.hashCode(), notification) } - override fun showNewNodeSeenNotification(node: NodeEntity) { + override fun showNewNodeSeenNotification(node: Node) { val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num) notificationManager.notify(node.num, notification) } - override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) { + override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) { val notification = createLowBatteryNotification(node, isRemote) notificationManager.notify(node.num, notification) } @@ -495,7 +493,7 @@ constructor( override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode()) - override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num) + override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num) override fun clearClientNotification(notification: ClientNotification) = notificationManager.cancel(notification.toString().hashCode()) @@ -673,11 +671,11 @@ constructor( return builder.build() } - private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification { + private fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification { val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal - val title = getString(Res.string.low_battery_title).format(node.shortName) - val batteryLevel = node.deviceMetrics?.battery_level ?: 0 - val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel) + val title = getString(Res.string.low_battery_title).format(node.user.short_name) + val batteryLevel = node.deviceMetrics.battery_level ?: 0 + val message = getString(Res.string.low_battery_message).format(node.user.long_name, batteryLevel) return commonBuilder(type, createOpenNodeDetailIntent(node.num)) .setCategory(Notification.CATEGORY_STATUS) diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt index 8462d8ec96..bea76c1478 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt @@ -25,8 +25,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import org.meshtastic.core.service.ServiceAction -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.ServiceRepository import javax.inject.Inject @AndroidEntryPoint diff --git a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt index a80839176c..e210396708 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt @@ -17,12 +17,19 @@ package com.geeksville.mesh.service import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent import androidx.core.app.RemoteInput import dagger.hilt.android.AndroidEntryPoint import jakarta.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.ServiceRepository /** * A [BroadcastReceiver] that handles inline replies from notifications. @@ -33,32 +40,42 @@ import org.meshtastic.core.service.ServiceRepository */ @AndroidEntryPoint class ReplyReceiver : BroadcastReceiver() { - @Inject lateinit var serviceRepository: ServiceRepository + @Inject lateinit var radioController: RadioController @Inject lateinit var meshServiceNotifications: MeshServiceNotifications + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + companion object { const val REPLY_ACTION = "com.geeksville.mesh.REPLY_ACTION" const val CONTACT_KEY = "contactKey" const val KEY_TEXT_REPLY = "key_text_reply" } - private fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") { - // contactKey: unique contact key filter (channel)+(nodeId) - val channel = contactKey[0].digitToIntOrNull() - val dest = if (channel != null) contactKey.substring(1) else contactKey - val p = DataPacket(dest, channel ?: 0, str) - serviceRepository.meshService?.send(p) - } - - override fun onReceive(context: android.content.Context, intent: android.content.Intent) { + override fun onReceive(context: Context, intent: Intent) { val remoteInput = RemoteInput.getResultsFromIntent(intent) if (remoteInput != null) { val contactKey = intent.getStringExtra(CONTACT_KEY) ?: "" val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: "" - sendMessage(message, contactKey) - meshServiceNotifications.cancelMessageNotification(contactKey) + + val pendingResult = goAsync() + scope.launch { + try { + sendMessage(message, contactKey) + meshServiceNotifications.cancelMessageNotification(contactKey) + } finally { + pendingResult.finish() + } + } } } + + private suspend fun sendMessage(str: String, contactKey: String) { + // contactKey: unique contact key filter (channel)+(nodeId) + val channel = contactKey.getOrNull(0)?.digitToIntOrNull() + val dest = if (channel != null) contactKey.substring(1) else contactKey + val p = DataPacket(dest, channel ?: 0, str) + radioController.sendMessage(p) + } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt similarity index 62% rename from app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt rename to app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt index 34ce09dec3..99d0bc7246 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt @@ -24,57 +24,99 @@ import dagger.hilt.android.qualifiers.ApplicationContext import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.util.toPIIString -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.ServiceRepository import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts @Singleton -class MeshServiceBroadcasts +class ServiceBroadcasts @Inject constructor( @ApplicationContext private val context: Context, - private val connectionStateHolder: ConnectionStateHandler, private val serviceRepository: ServiceRepository, -) { +) : SharedServiceBroadcasts { // A mapping of receiver class name to package name - used for explicit broadcasts private val clientPackages = mutableMapOf() - fun subscribeReceiver(receiverName: String, packageName: String) { + override fun subscribeReceiver(receiverName: String, packageName: String) { clientPackages[receiverName] = packageName } /** Broadcast some received data Payload will be a DataPacket */ - fun broadcastReceivedData(payload: DataPacket) { - val action = MeshService.actionReceived(payload.dataType) - explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, payload)) + override fun broadcastReceivedData(dataPacket: DataPacket) { + val action = MeshService.actionReceived(dataPacket.dataType) + explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket)) // Also broadcast with the numeric port number for backwards compatibility with some apps - val numericAction = actionReceived(payload.dataType.toString()) + val numericAction = actionReceived(dataPacket.dataType.toString()) if (numericAction != action) { - explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, payload)) + explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket)) } } - fun broadcastNodeChange(info: NodeInfo) { - Logger.d { "Broadcasting node change ${info.user?.toPIIString()}" } - val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info) + override fun broadcastNodeChange(node: Node) { + Logger.d { "Broadcasting node change ${node.user.toPIIString()}" } + val legacy = node.toLegacy() + val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy) explicitBroadcast(intent) } - fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status) + private fun Node.toLegacy(): NodeInfo = NodeInfo( + num = num, + user = + org.meshtastic.core.model.MeshUser( + id = user.id, + longName = user.long_name, + shortName = user.short_name, + hwModel = user.hw_model, + role = user.role.value, + ), + position = + org.meshtastic.core.model + .Position( + latitude = latitude, + longitude = longitude, + altitude = position.altitude ?: 0, + time = position.time, + satellitesInView = position.sats_in_view ?: 0, + groundSpeed = position.ground_speed ?: 0, + groundTrack = position.ground_track ?: 0, + precisionBits = position.precision_bits ?: 0, + ) + .takeIf { latitude != 0.0 || longitude != 0.0 }, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceMetrics = + org.meshtastic.core.model.DeviceMetrics( + batteryLevel = deviceMetrics.battery_level ?: 0, + voltage = deviceMetrics.voltage ?: 0f, + channelUtilization = deviceMetrics.channel_utilization ?: 0f, + airUtilTx = deviceMetrics.air_util_tx ?: 0f, + uptimeSeconds = deviceMetrics.uptime_seconds ?: 0, + ), + channel = channel, + environmentMetrics = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0), + hopsAway = hopsAway, + nodeStatus = nodeStatus, + ) - fun broadcastMessageStatus(id: Int, status: MessageStatus?) { - if (id == 0) { + fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) + + override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) { + if (packetId == 0) { Logger.d { "Ignoring anonymous packet status" } } else { // Do not log, contains PII possibly // MeshService.Logger.d { "Broadcasting message status $p" } val intent = Intent(ACTION_MESSAGE_STATUS).apply { - putExtra(EXTRA_PACKET_ID, id) + putExtra(EXTRA_PACKET_ID, packetId) putExtra(EXTRA_STATUS, status as Parcelable) } explicitBroadcast(intent) @@ -82,14 +124,13 @@ constructor( } /** Broadcast our current connection status */ - fun broadcastConnection() { - val connectionState = connectionStateHolder.connectionState.value + override fun broadcastConnection() { + val connectionState = serviceRepository.connectionState.value // ATAK expects a String: "CONNECTED" or "DISCONNECTED" // It uses equalsIgnoreCase, but we'll use uppercase to be specific. val stateStr = connectionState.toString().uppercase(Locale.ROOT) val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) } - serviceRepository.setConnectionState(connectionState) explicitBroadcast(intent) if (connectionState == ConnectionState.Disconnected) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index f28f98114f..f41dcd8e1b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -85,7 +84,6 @@ import com.geeksville.mesh.navigation.firmwareGraph import com.geeksville.mesh.navigation.mapGraph import com.geeksville.mesh.navigation.nodesGraph import com.geeksville.mesh.navigation.settingsGraph -import com.geeksville.mesh.repository.radio.MeshActivity import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.ui.connections.DeviceType import com.geeksville.mesh.ui.connections.ScannerViewModel @@ -98,6 +96,7 @@ import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceVersion +import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.navigation.ConnectionsRoutes import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.MapRoutes @@ -464,7 +463,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerVie private fun VersionChecks(viewModel: UIViewModel) { val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle() - val context = LocalContext.current val myFirmwareVersion = myNodeInfo?.firmwareVersion @@ -499,10 +497,7 @@ private fun VersionChecks(viewModel: UIViewModel) { viewModel.showAlert( titleRes = Res.string.app_too_old, messageRes = Res.string.must_update, - onConfirm = { - val service = viewModel.meshService ?: return@showAlert - MeshService.changeDeviceAddress(context, service, "n") - }, + onConfirm = { viewModel.setDeviceAddress("n") }, ) } else { myFirmwareVersion @@ -526,10 +521,7 @@ private fun VersionChecks(viewModel: UIViewModel) { viewModel.showAlert( title = title, html = message, - onConfirm = { - val service = viewModel.meshService ?: return@showAlert - MeshService.changeDeviceAddress(context, service, "n") - }, + onConfirm = { viewModel.setDeviceAddress("n") }, ) } else if (curVer < MeshService.minDeviceVersion) { Logger.w { diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt index 88e9391f58..b17281ff6b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt @@ -21,12 +21,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node import org.meshtastic.core.prefs.ui.UiPrefs -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig import javax.inject.Inject @@ -46,7 +46,7 @@ constructor( val connectionState = serviceRepository.connectionState - val myNodeInfo: StateFlow = nodeRepository.myNodeInfo + val myNodeInfo: StateFlow = nodeRepository.myNodeInfo val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt index 131eb33e8f..0bfba1faf0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt @@ -16,18 +16,13 @@ */ package com.geeksville.mesh.ui.connections -import android.app.Application -import android.content.Context -import android.os.RemoteException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import com.geeksville.mesh.domain.usecase.GetDiscoveredDevicesUseCase import com.geeksville.mesh.model.DeviceListEntry -import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.usb.UsbRepository -import com.geeksville.mesh.service.MeshService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -42,8 +37,10 @@ import kotlinx.coroutines.launch import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import javax.inject.Inject @@ -52,17 +49,14 @@ import javax.inject.Inject class ScannerViewModel @Inject constructor( - private val application: Application, private val serviceRepository: ServiceRepository, + private val radioController: RadioController, private val bluetoothRepository: BluetoothRepository, private val usbRepository: UsbRepository, private val radioInterfaceService: RadioInterfaceService, private val recentAddressesDataSource: RecentAddressesDataSource, private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase, ) : ViewModel() { - private val context: Context - get() = application.applicationContext - val showMockInterface: StateFlow = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() private val _errorText = MutableStateFlow(null) @@ -117,11 +111,8 @@ constructor( } private fun changeDeviceAddress(address: String) { - try { - serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) } - } catch (ex: RemoteException) { - Logger.e(ex) { "changeDeviceSelection failed, probably it is shutting down" } - } + Logger.i { "Attempting to change device address to ${address.anonymize()}" } + radioController.setDeviceAddress(address) } /** Initiates the bonding process and connects to the device upon success. */ diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt index eb359ca00d..9bf5f3fbc9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt @@ -47,7 +47,7 @@ import no.nordicsemi.android.common.ui.view.RssiIcon import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disconnect import org.meshtastic.core.resources.firmware_version diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt index dc5f2a7b45..c5ba9bec44 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt @@ -17,7 +17,6 @@ package com.geeksville.mesh.ui.sharing import android.net.Uri -import android.os.RemoteException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger @@ -27,9 +26,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.meshtastic.core.analytics.DataPair import org.meshtastic.core.analytics.platform.PlatformAnalytics -import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.toChannelSet -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel @@ -42,12 +41,12 @@ import javax.inject.Inject class ChannelViewModel @Inject constructor( - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, private val radioConfigRepository: RadioConfigRepository, private val analytics: PlatformAnalytics, ) : ViewModel() { - val connectionState = serviceRepository.connectionState + val connectionState = radioController.connectionState val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) @@ -95,20 +94,12 @@ constructor( } fun setChannel(channel: Channel) { - try { - serviceRepository.meshService?.setChannel(channel.encode()) - } catch (ex: RemoteException) { - Logger.e(ex) { "Set channel error" } - } + viewModelScope.launch { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { - try { - serviceRepository.meshService?.setConfig(config.encode()) - } catch (ex: RemoteException) { - Logger.e(ex) { "Set config error" } - } + viewModelScope.launch { radioController.setLocalConfig(config) } } fun trackShare() { diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt index eafbe38a2f..1f28a65f7c 100644 --- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt +++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt @@ -28,11 +28,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LocalStats import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt index 16d6b566ed..6a044c90e5 100644 --- a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt +++ b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt @@ -20,22 +20,22 @@ import android.content.Context import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback -import com.geeksville.mesh.service.MeshCommandSender -import com.geeksville.mesh.service.MeshNodeManager import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeManager class RefreshLocalStatsAction : ActionCallback { @EntryPoint @InstallIn(SingletonComponent::class) interface RefreshLocalStatsEntryPoint { - fun commandSender(): MeshCommandSender + fun commandSender(): CommandSender - fun nodeManager(): MeshNodeManager + fun nodeManager(): NodeManager } override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { diff --git a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt index d980d265e3..a468896fb3 100644 --- a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt +++ b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt @@ -31,8 +31,8 @@ import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.service.startService import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.SERVICE_NOTIFY_ID +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.SERVICE_NOTIFY_ID /** * A worker whose sole purpose is to start the MeshService from the background. This is used as a fallback when diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt index eb4ac385d1..41cceafe24 100644 --- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC +import org.meshtastic.core.repository.RadioInterfaceService import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt index 2974d30295..1ee5ff9eed 100644 --- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt @@ -47,6 +47,7 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC +import org.meshtastic.core.repository.RadioInterfaceService import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) @@ -662,7 +663,7 @@ class NordicBleInterfaceTest { advanceUntilIdle() // Verify handleFromRadio was called directly with the payload - verify(timeout = 2000) { service.handleFromRadio(p = payload) } + verify(timeout = 2000) { service.handleFromRadio(payload) } nordicInterface.close() } diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt index b0ddc037ea..868c5197f3 100644 --- a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt +++ b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt @@ -20,6 +20,7 @@ import io.mockk.confirmVerified import io.mockk.mockk import io.mockk.verify import org.junit.Test +import org.meshtastic.core.repository.RadioInterfaceService class StreamInterfaceTest { 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 19b187bdc0..86ecc7fb93 100644 --- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt +++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt @@ -17,10 +17,10 @@ package com.geeksville.mesh.service import android.app.Notification -import com.geeksville.mesh.repository.radio.RadioInterfaceService import io.mockk.mockk -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry @@ -64,15 +64,15 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override fun showAlertNotification(contactKey: String, name: String, alert: String) {} - override fun showNewNodeSeenNotification(node: NodeEntity) {} + override fun showNewNodeSeenNotification(node: Node) {} - override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {} + override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {} override fun showClientNotification(clientNotification: ClientNotification) {} override fun cancelMessageNotification(contactKey: String) {} - override fun cancelLowBatteryNotification(node: NodeEntity) {} + override fun cancelLowBatteryNotification(node: Node) {} override fun clearClientNotification(notification: ClientNotification) {} } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt deleted file mode 100644 index 9b3aa4cfcc..0000000000 --- a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (c) 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.service - -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test -import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.Data -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum - -class MeshMessageProcessorTest { - - private val nodeManager: MeshNodeManager = mockk(relaxed = true) - private val serviceRepository: ServiceRepository = mockk(relaxed = true) - private val meshLogRepository: MeshLogRepository = mockk(relaxed = true) - private val router: MeshRouter = mockk(relaxed = true) - private val fromRadioDispatcher: FromRadioPacketHandler = mockk(relaxed = true) - private val meshLogRepositoryLazy = dagger.Lazy { meshLogRepository } - private val dataHandler: MeshDataHandler = mockk(relaxed = true) - - private val isNodeDbReady = MutableStateFlow(false) - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var processor: MeshMessageProcessor - - @Before - fun setUp() { - every { nodeManager.isNodeDbReady } returns isNodeDbReady - every { router.dataHandler } returns dataHandler - processor = - MeshMessageProcessor(nodeManager, serviceRepository, meshLogRepositoryLazy, router, fromRadioDispatcher) - processor.start(testScope) - } - - @Test - fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) { - val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) - - // 1. Database is NOT ready - isNodeDbReady.value = false - testScheduler.runCurrent() // trigger start() onEach - - processor.handleReceivedMeshPacket(packet, 999) - - // Verify that handleReceivedData has NOT been called yet - verify(exactly = 0) { dataHandler.handleReceivedData(any(), any(), any(), any()) } - - // 2. Database becomes ready - isNodeDbReady.value = true - testScheduler.runCurrent() // trigger onEach(true) - - // Verify that handleReceivedData is now called with the buffered packet - verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 123 }, any(), any(), any()) } - } - - @Test - fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) { - val packet = MeshPacket(id = 456, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) - - isNodeDbReady.value = true - testScheduler.runCurrent() - - processor.handleReceivedMeshPacket(packet, 999) - - verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 456 }, any(), any(), any()) } - } - - @Test - fun `packets from local node are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) { - val myNodeNum = 1234 - val packet = MeshPacket(from = myNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) - - isNodeDbReady.value = true - testScheduler.runCurrent() - - processor.handleReceivedMeshPacket(packet, myNodeNum) - testScheduler.runCurrent() // wait for log insert job - - coVerify { meshLogRepository.insert(match { log -> log.fromNum == MeshLog.NODE_NUM_LOCAL }) } - } - - @Test - fun `packets from remote nodes are logged with their node number`() = runTest(testDispatcher) { - val myNodeNum = 1234 - val remoteNodeNum = 5678 - val packet = MeshPacket(from = remoteNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) - - isNodeDbReady.value = true - testScheduler.runCurrent() - - processor.handleReceivedMeshPacket(packet, myNodeNum) - testScheduler.runCurrent() - - coVerify { meshLogRepository.insert(match { log -> log.fromNum == remoteNodeNum }) } - } -} diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt similarity index 82% rename from app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt rename to app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt index 88cee4a4bc..3ddfecd61b 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt +++ b/app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt @@ -19,35 +19,36 @@ package com.geeksville.mesh.service import android.app.Application import android.content.Context import androidx.test.core.app.ApplicationProvider +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.ServiceRepository import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) -class MeshServiceBroadcastsTest { +class ServiceBroadcastsTest { private lateinit var context: Context - private val connectionStateHolder = ConnectionStateHandler() private val serviceRepository: ServiceRepository = mockk(relaxed = true) - private lateinit var broadcasts: MeshServiceBroadcasts + private lateinit var broadcasts: ServiceBroadcasts @Before fun setUp() { context = ApplicationProvider.getApplicationContext() - broadcasts = MeshServiceBroadcasts(context, connectionStateHolder, serviceRepository) + broadcasts = ServiceBroadcasts(context, serviceRepository) } @Test fun `broadcastConnection sends uppercase state string for ATAK`() { - connectionStateHolder.setState(ConnectionState.Connected) + every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected) broadcasts.broadcastConnection() @@ -58,7 +59,7 @@ class MeshServiceBroadcastsTest { @Test fun `broadcastConnection sends legacy connection intent`() { - connectionStateHolder.setState(ConnectionState.Connected) + every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected) broadcasts.broadcastConnection() diff --git a/compose_compiler_config.conf b/compose_compiler_config.conf index 5952a81bdf..032dc04e05 100644 --- a/compose_compiler_config.conf +++ b/compose_compiler_config.conf @@ -3,8 +3,8 @@ // For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file // Meshtastic Models -org.meshtastic.core.database.model.Node -org.meshtastic.core.database.model.Message +org.meshtastic.core.model.Node +org.meshtastic.core.model.Message org.meshtastic.core.database.entity.Reaction org.meshtastic.core.database.entity.ReactionEntity org.meshtastic.core.model.** diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt index e58e804b68..8861b8a11b 100644 --- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt +++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt @@ -81,7 +81,7 @@ constructor( @SuppressLint("MissingPermission") suspend fun bond(peripheral: Peripheral) { peripheral.createBond() - refreshState() + updateBluetoothState() } internal suspend fun updateBluetoothState() { @@ -112,6 +112,24 @@ constructor( emptyList() } + /** @return true if the given address is currently bonded to the system. */ + @SuppressLint("MissingPermission") + fun isBonded(address: String): Boolean { + val enabled = androidEnvironment.isBluetoothEnabled + val hasPerms = + if (androidEnvironment.requiresBluetoothRuntimePermissions) { + androidEnvironment.isBluetoothScanPermissionGranted && + androidEnvironment.isBluetoothConnectPermissionGranted + } else { + androidEnvironment.isLocationPermissionGranted + } + return if (enabled && hasPerms) { + centralManager.getBondedPeripherals().any { it.address == address } + } else { + false + } + } + /** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */ private fun isMatchingPeripheral(peripheral: Peripheral): Boolean { val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 1f06437b66..90a438478e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -26,6 +26,7 @@ plugins { configure { namespace = "org.meshtastic.core.data" } dependencies { + api(projects.core.repository) implementation(projects.core.analytics) implementation(projects.core.common) implementation(projects.core.database) diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt new file mode 100644 index 0000000000..333398c103 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt @@ -0,0 +1,151 @@ +/* + * 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.data.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.meshtastic.core.data.manager.CommandSenderImpl +import org.meshtastic.core.data.manager.FromRadioPacketHandlerImpl +import org.meshtastic.core.data.manager.HistoryManagerImpl +import org.meshtastic.core.data.manager.MeshActionHandlerImpl +import org.meshtastic.core.data.manager.MeshConfigFlowManagerImpl +import org.meshtastic.core.data.manager.MeshConfigHandlerImpl +import org.meshtastic.core.data.manager.MeshConnectionManagerImpl +import org.meshtastic.core.data.manager.MeshDataHandlerImpl +import org.meshtastic.core.data.manager.MeshMessageProcessorImpl +import org.meshtastic.core.data.manager.MeshRouterImpl +import org.meshtastic.core.data.manager.MessageFilterImpl +import org.meshtastic.core.data.manager.MqttManagerImpl +import org.meshtastic.core.data.manager.NeighborInfoHandlerImpl +import org.meshtastic.core.data.manager.NodeManagerImpl +import org.meshtastic.core.data.manager.PacketHandlerImpl +import org.meshtastic.core.data.manager.TracerouteHandlerImpl +import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl +import org.meshtastic.core.data.repository.NodeRepositoryImpl +import org.meshtastic.core.data.repository.PacketRepositoryImpl +import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl +import org.meshtastic.core.model.util.MeshDataMapper +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.FromRadioPacketHandler +import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.MeshActionHandler +import org.meshtastic.core.repository.MeshConfigFlowManager +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MessageFilter +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.NeighborInfoHandler +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.TracerouteHandler +import javax.inject.Singleton + +@Suppress("TooManyFunctions") +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds @Singleton + abstract fun bindNodeRepository(nodeRepositoryImpl: NodeRepositoryImpl): NodeRepository + + @Binds + @Singleton + abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository + + @Binds + @Singleton + abstract fun bindDeviceHardwareRepository( + deviceHardwareRepositoryImpl: DeviceHardwareRepositoryImpl, + ): DeviceHardwareRepository + + @Binds @Singleton + abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository + + @Binds @Singleton + abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager + + @Binds @Singleton + abstract fun bindCommandSender(commandSenderImpl: CommandSenderImpl): CommandSender + + @Binds @Singleton + abstract fun bindHistoryManager(historyManagerImpl: HistoryManagerImpl): HistoryManager + + @Binds + @Singleton + abstract fun bindTracerouteHandler(tracerouteHandlerImpl: TracerouteHandlerImpl): TracerouteHandler + + @Binds + @Singleton + abstract fun bindNeighborInfoHandler(neighborInfoHandlerImpl: NeighborInfoHandlerImpl): NeighborInfoHandler + + @Binds @Singleton + abstract fun bindMqttManager(mqttManagerImpl: MqttManagerImpl): MqttManager + + @Binds @Singleton + abstract fun bindPacketHandler(packetHandlerImpl: PacketHandlerImpl): PacketHandler + + @Binds + @Singleton + abstract fun bindMeshConnectionManager(meshConnectionManagerImpl: MeshConnectionManagerImpl): MeshConnectionManager + + @Binds @Singleton + abstract fun bindMeshDataHandler(meshDataHandlerImpl: MeshDataHandlerImpl): MeshDataHandler + + @Binds + @Singleton + abstract fun bindMeshActionHandler(meshActionHandlerImpl: MeshActionHandlerImpl): MeshActionHandler + + @Binds + @Singleton + abstract fun bindMeshMessageProcessor(meshMessageProcessorImpl: MeshMessageProcessorImpl): MeshMessageProcessor + + @Binds @Singleton + abstract fun bindMeshRouter(meshRouterImpl: MeshRouterImpl): MeshRouter + + @Binds + @Singleton + abstract fun bindFromRadioPacketHandler( + fromRadioPacketHandlerImpl: FromRadioPacketHandlerImpl, + ): FromRadioPacketHandler + + @Binds + @Singleton + abstract fun bindMeshConfigHandler(meshConfigHandlerImpl: MeshConfigHandlerImpl): MeshConfigHandler + + @Binds + @Singleton + abstract fun bindMeshConfigFlowManager(meshConfigFlowManagerImpl: MeshConfigFlowManagerImpl): MeshConfigFlowManager + + @Binds @Singleton + abstract fun bindMessageFilter(messageFilterImpl: MessageFilterImpl): MessageFilter + + companion object { + @Provides + @Singleton + fun provideMeshDataMapper(nodeManager: NodeManager): MeshDataMapper = MeshDataMapper(nodeManager) + } +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt new file mode 100644 index 0000000000..8093d73e91 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt @@ -0,0 +1,45 @@ +/* + * 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.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MessageQueue +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.usecase.SendMessageUseCase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object UseCaseModule { + + @Provides + @Singleton + fun provideSendMessageUseCase( + nodeRepository: NodeRepository, + packetRepository: PacketRepository, + radioController: RadioController, + homoglyphEncodingPrefs: HomoglyphPrefs, + messageQueue: MessageQueue, + ): SendMessageUseCase = + SendMessageUseCase(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue) +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt similarity index 77% rename from app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index 6e98b253ee..4f262071c7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -14,10 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager -import android.os.RemoteException -import androidx.annotation.VisibleForTesting import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,14 +26,15 @@ import kotlinx.coroutines.flow.onEach import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Position import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.isWithinSizeLimit +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Constants @@ -54,55 +53,56 @@ import javax.inject.Singleton import kotlin.math.absoluteValue import kotlin.time.Duration.Companion.hours -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "CyclomaticComplexMethod") @Singleton -class MeshCommandSender +class CommandSenderImpl @Inject constructor( - private val packetHandler: PacketHandler?, - private val nodeManager: MeshNodeManager?, - private val connectionStateHolder: ConnectionStateHandler?, - private val radioConfigRepository: RadioConfigRepository?, -) { + private val packetHandler: PacketHandler, + private val nodeManager: NodeManager, + private val radioConfigRepository: RadioConfigRepository, +) : CommandSender { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue) private val sessionPasskey = AtomicReference(ByteString.EMPTY) - val tracerouteStartTimes = ConcurrentHashMap() - val neighborInfoStartTimes = ConcurrentHashMap() + override val tracerouteStartTimes = ConcurrentHashMap() + override val neighborInfoStartTimes = ConcurrentHashMap() private val localConfig = MutableStateFlow(LocalConfig()) private val channelSet = MutableStateFlow(ChannelSet()) - @Volatile var lastNeighborInfo: NeighborInfo? = null + override var lastNeighborInfo: NeighborInfo? = null - fun start(scope: CoroutineScope) { + // We'll need a way to track connection state in shared code, + // maybe via ServiceRepository or similar. + // For now I'll assume it's injected or available. + + override fun start(scope: CoroutineScope) { this.scope = scope - radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope) - radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope) + radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope) + radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope) } - fun getCachedLocalConfig(): LocalConfig = localConfig.value - - fun getCachedChannelSet(): ChannelSet = channelSet.value + override fun getCachedLocalConfig(): LocalConfig = localConfig.value - @VisibleForTesting internal constructor() : this(null, null, null, null) + override fun getCachedChannelSet(): ChannelSet = channelSet.value - fun getCurrentPacketId(): Long = currentPacketId.get() + override fun getCurrentPacketId(): Long = currentPacketId.get() - fun generatePacketId(): Int { + override fun generatePacketId(): Int { val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1) val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK return ((next % numPacketIds) + 1L).toInt() } - fun setSessionPasskey(key: ByteString) { + override fun setSessionPasskey(key: ByteString) { sessionPasskey.set(key) } private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT private fun getAdminChannelIndex(toNum: Int): Int { - val myNum = nodeManager?.myNodeNum ?: return 0 + val myNum = nodeManager.myNodeNum ?: return 0 val myNode = nodeManager.nodeDBbyNodeNum[myNum] val destNode = nodeManager.nodeDBbyNodeNum[toNum] @@ -118,7 +118,7 @@ constructor( return adminChannelIndex } - fun sendData(p: DataPacket) { + override fun sendData(p: DataPacket) { if (p.id == 0) p.id = generatePacketId() val bytes = p.bytes ?: ByteString.EMPTY require(p.dataType != 0) { "Port numbers must be non-zero!" } @@ -135,16 +135,15 @@ constructor( if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) { val actualSize = Data.ADAPTER.encodedSize(data) p.status = MessageStatus.ERROR - throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})") + // throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})") + // RemoteException is Android specific. For KMP we might want a custom exception. + error("Message too long: $actualSize bytes") } else { p.status = MessageStatus.QUEUED } - if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) { - sendNow(p) - } else { - error("Radio is not connected") - } + // TODO: Check connection state + sendNow(p) } private fun sendNow(p: DataPacket) { @@ -164,31 +163,26 @@ constructor( ), ) p.time = nowMillis - packetHandler?.sendToRadio(meshPacket) + packetHandler.sendToRadio(meshPacket) } - fun sendAdmin( - destNum: Int, - requestId: Int = generatePacketId(), - wantResponse: Boolean = false, - initFn: () -> AdminMessage, - ) { + override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) { val adminMsg = initFn().copy(session_passkey = sessionPasskey.get()) val packet = buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg) - packetHandler?.sendToRadio(packet) + packetHandler.sendToRadio(packet) } - fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) { - val myNum = nodeManager?.myNodeNum ?: return + override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) { + val myNum = nodeManager.myNodeNum ?: return val idNum = destNum ?: myNum Logger.d { "Sending our position/time to=$idNum $pos" } if (localConfig.value.position?.fixed_position != true) { - nodeManager.handleReceivedPosition(myNum, myNum, pos) + nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis) } - packetHandler?.sendToRadio( + packetHandler.sendToRadio( buildMeshPacket( to = idNum, channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, @@ -203,18 +197,18 @@ constructor( ) } - fun requestPosition(destNum: Int, currentPosition: Position) { + override fun requestPosition(destNum: Int, currentPosition: Position) { val meshPosition = org.meshtastic.proto.Position( latitude_i = Position.degI(currentPosition.latitude), longitude_i = Position.degI(currentPosition.longitude), altitude = currentPosition.altitude, - time = nowSeconds.toInt(), + time = (nowMillis / 1000L).toInt(), ) - packetHandler?.sendToRadio( + packetHandler.sendToRadio( buildMeshPacket( to = destNum, - channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, priority = MeshPacket.Priority.BACKGROUND, decoded = Data( @@ -226,7 +220,7 @@ constructor( ) } - fun setFixedPosition(destNum: Int, pos: Position) { + override fun setFixedPosition(destNum: Int, pos: Position) { val meshPos = org.meshtastic.proto.Position( latitude_i = Position.degI(pos.latitude), @@ -240,13 +234,13 @@ constructor( AdminMessage(remove_fixed_position = true) } } - nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos) + nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos, nowMillis) } - fun requestUserInfo(destNum: Int) { - val myNum = nodeManager?.myNodeNum ?: return - val myNode = nodeManager.getOrCreateNodeInfo(myNum) - packetHandler?.sendToRadio( + override fun requestUserInfo(destNum: Int) { + val myNum = nodeManager.myNodeNum ?: return + val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return + packetHandler.sendToRadio( buildMeshPacket( to = destNum, channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, @@ -260,20 +254,20 @@ constructor( ) } - fun requestTraceroute(requestId: Int, destNum: Int) { + override fun requestTraceroute(requestId: Int, destNum: Int) { tracerouteStartTimes[requestId] = nowMillis - packetHandler?.sendToRadio( + packetHandler.sendToRadio( buildMeshPacket( to = destNum, wantAck = true, id = requestId, - channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true), ), ) } - fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { + override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE val portNum: PortNum @@ -301,19 +295,19 @@ constructor( .toByteString() } - packetHandler?.sendToRadio( + packetHandler.sendToRadio( buildMeshPacket( to = destNum, id = requestId, - channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true), ), ) } - fun requestNeighborInfo(requestId: Int, destNum: Int) { + override fun requestNeighborInfo(requestId: Int, destNum: Int) { neighborInfoStartTimes[requestId] = nowMillis - val myNum = nodeManager?.myNodeNum ?: 0 + val myNum = nodeManager.myNodeNum ?: 0 if (destNum == myNum) { val neighborInfoToSend = lastNeighborInfo @@ -329,7 +323,7 @@ constructor( Neighbor( node_id = 0, // Dummy node ID that can be intercepted snr = 0f, - last_rx_time = nowSeconds.toInt(), + last_rx_time = (nowMillis / 1000L).toInt(), node_broadcast_interval_secs = oneHour, ), ), @@ -337,12 +331,12 @@ constructor( } // Send the neighbor info from our connected radio to ourselves (simulated) - packetHandler?.sendToRadio( + packetHandler.sendToRadio( buildMeshPacket( to = destNum, wantAck = true, id = requestId, - channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, decoded = Data( portnum = PortNum.NEIGHBORINFO_APP, @@ -353,20 +347,19 @@ constructor( ) } else { // Send request to remote - packetHandler?.sendToRadio( + packetHandler.sendToRadio( buildMeshPacket( to = destNum, wantAck = true, id = requestId, - channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0, + channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0, decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true), ), ) } } - @VisibleForTesting - internal fun resolveNodeNum(toId: String): Int = when (toId) { + fun resolveNodeNum(toId: String): Int = when (toId) { DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST else -> { val numericNum = @@ -376,7 +369,7 @@ constructor( null } numericNum - ?: nodeManager?.nodeDBbyID?.get(toId)?.num + ?: nodeManager.nodeDBbyID[toId]?.num ?: throw IllegalArgumentException("Unknown node ID $toId") } } @@ -398,12 +391,12 @@ constructor( if (channel == DataPacket.PKC_CHANNEL_INDEX) { pkiEncrypted = true - publicKey = nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.public_key ?: ByteString.EMPTY + publicKey = nodeManager.nodeDBbyNodeNum[to]?.user?.public_key ?: ByteString.EMPTY actualChannel = 0 } return MeshPacket( - from = nodeManager?.myNodeNum ?: 0, + from = nodeManager.myNodeNum ?: 0, to = to, id = id, want_ack = wantAck, diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt similarity index 53% rename from app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index a771b6fa2e..081d1a2078 100644 --- a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -14,31 +14,32 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager -import co.touchlab.kermit.Logger -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceRepository +import dagger.Lazy +import org.meshtastic.core.repository.FromRadioPacketHandler +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.FromRadio import javax.inject.Inject import javax.inject.Singleton -/** - * Dispatches non-packet [FromRadio] variants to their respective handlers. This class is stateless and handles routing - * for config, metadata, and specialized system messages. - */ +/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */ @Singleton -class FromRadioPacketHandler +class FromRadioPacketHandlerImpl @Inject constructor( private val serviceRepository: ServiceRepository, - private val router: MeshRouter, - private val mqttManager: MeshMqttManager, + private val router: Lazy, + private val mqttManager: MqttManager, private val packetHandler: PacketHandler, private val serviceNotifications: MeshServiceNotifications, -) { +) : FromRadioPacketHandler { @Suppress("CyclomaticComplexMethod") - fun handleFromRadio(proto: FromRadio) { + override fun handleFromRadio(proto: FromRadio) { val myInfo = proto.my_info val metadata = proto.metadata val nodeInfo = proto.node_info @@ -51,34 +52,23 @@ constructor( val clientNotification = proto.clientNotification when { - myInfo != null -> router.configFlowManager.handleMyInfo(myInfo) - metadata != null -> router.configFlowManager.handleLocalMetadata(metadata) + myInfo != null -> router.get().configFlowManager.handleMyInfo(myInfo) + metadata != null -> router.get().configFlowManager.handleLocalMetadata(metadata) nodeInfo != null -> { - router.configFlowManager.handleNodeInfo(nodeInfo) - serviceRepository.setConnectionProgress("Nodes (${router.configFlowManager.newNodeCount})") + router.get().configFlowManager.handleNodeInfo(nodeInfo) + serviceRepository.setConnectionProgress("Nodes (${router.get().configFlowManager.newNodeCount})") } - configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId) + configCompleteId != null -> router.get().configFlowManager.handleConfigComplete(configCompleteId) mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage) queueStatus != null -> packetHandler.handleQueueStatus(queueStatus) - config != null -> router.configHandler.handleDeviceConfig(config) - moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig) - channel != null -> router.configHandler.handleChannel(channel) + config != null -> router.get().configHandler.handleDeviceConfig(config) + moduleConfig != null -> router.get().configHandler.handleModuleConfig(moduleConfig) + channel != null -> router.get().configHandler.handleChannel(channel) clientNotification != null -> { serviceRepository.setClientNotification(clientNotification) serviceNotifications.showClientNotification(clientNotification) - packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false) - } - // Logging-only variants are handled by MeshMessageProcessor before dispatching here - proto.packet != null || - proto.log_record != null || - proto.rebooted != null || - proto.xmodemPacket != null || - proto.deviceuiConfig != null || - proto.fileInfo != null -> { - /* No specialized routing needed here */ + packetHandler.removeResponse(0, complete = false) } - - else -> Logger.d { "Dispatcher ignoring FromRadio variant" } } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt similarity index 74% rename from app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt index b084433b41..a2df3d73a1 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt @@ -14,15 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager -import android.util.Log -import androidx.annotation.VisibleForTesting import co.touchlab.kermit.Logger -import com.geeksville.mesh.BuildConfig -import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED import okio.ByteString.Companion.toByteString import org.meshtastic.core.prefs.mesh.MeshPrefs +import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.PacketHandler import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig @@ -32,19 +30,20 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class MeshHistoryManager +class HistoryManagerImpl @Inject constructor( private val meshPrefs: MeshPrefs, private val packetHandler: PacketHandler, -) { +) : HistoryManager { + companion object { private const val HISTORY_TAG = "HistoryReplay" private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24 private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100 + private const val NO_DEVICE_SELECTED = "No device selected" - @VisibleForTesting - internal fun buildStoreForwardHistoryRequest( + fun buildStoreForwardHistoryRequest( lastRequest: Int, historyReturnWindow: Int, historyReturnMax: Int, @@ -58,32 +57,23 @@ constructor( return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history) } - @VisibleForTesting - internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair { + fun resolveHistoryRequestParameters(window: Int, max: Int): Pair { val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES return resolvedWindow to resolvedMax } } - private fun historyLog(priority: Int = Log.INFO, throwable: Throwable? = null, message: () -> String) { - if (!BuildConfig.DEBUG) return - val logger = Logger.withTag(HISTORY_TAG) - val msg = message() - when (priority) { - Log.VERBOSE -> logger.v(throwable) { msg } - Log.DEBUG -> logger.d(throwable) { msg } - Log.INFO -> logger.i(throwable) { msg } - Log.WARN -> logger.w(throwable) { msg } - Log.ERROR -> logger.e(throwable) { msg } - else -> logger.i(throwable) { msg } - } + private val logger = Logger.withTag(HISTORY_TAG) + + private fun historyLog(message: String, throwable: Throwable? = null) { + logger.i(throwable) { message } } private fun activeDeviceAddress(): String? = meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() } - fun requestHistoryReplay( + override fun requestHistoryReplay( trigger: String, myNodeNum: Int?, storeForwardConfig: ModuleConfig.StoreForwardConfig?, @@ -92,7 +82,7 @@ constructor( val address = activeDeviceAddress() if (address == null || myNodeNum == null) { val reason = if (address == null) "no_addr" else "no_my_node" - historyLog { "requestHistory skipped trigger=$trigger reason=$reason" } + historyLog("requestHistory skipped trigger=$trigger reason=$reason") return } @@ -105,10 +95,10 @@ constructor( val request = buildStoreForwardHistoryRequest(lastRequest, window, max) - historyLog { + historyLog( "requestHistory trigger=$trigger transport=$transport addr=$address " + - "lastRequest=$lastRequest window=$window max=$max" - } + "lastRequest=$lastRequest window=$window max=$max", + ) runCatching { packetHandler.sendToRadio( @@ -120,19 +110,19 @@ constructor( ), ) } - .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } } + .onFailure { ex -> logger.w(ex) { "requestHistory failed" } } } - fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) { + override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) { if (lastRequest <= 0) return val address = activeDeviceAddress() ?: return val current = meshPrefs.getStoreForwardLastRequest(address) if (lastRequest != current) { meshPrefs.setStoreForwardLastRequest(address, lastRequest) - historyLog { + historyLog( "historyMarker updated source=$source transport=$transport " + - "addr=$address from=$current to=$lastRequest" - } + "addr=$address from=$current to=$lastRequest", + ) } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt similarity index 74% rename from app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index 5ac1ee1cfe..0adf6a80ee 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import dagger.Lazy import kotlinx.coroutines.CoroutineScope @@ -26,15 +26,22 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ignoreException import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.DatabaseManager -import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MeshUser import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Position +import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.prefs.mesh.MeshPrefs -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceAction +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.DatabaseManager +import org.meshtastic.core.repository.MeshActionHandler +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -47,23 +54,23 @@ import javax.inject.Singleton @Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") @Singleton -class MeshActionHandler +class MeshActionHandlerImpl @Inject constructor( - private val nodeManager: MeshNodeManager, - private val commandSender: MeshCommandSender, + private val nodeManager: NodeManager, + private val commandSender: CommandSender, private val packetRepository: Lazy, - private val serviceBroadcasts: MeshServiceBroadcasts, - private val dataHandler: MeshDataHandler, + private val serviceBroadcasts: ServiceBroadcasts, + private val dataHandler: Lazy, private val analytics: PlatformAnalytics, private val meshPrefs: MeshPrefs, private val databaseManager: DatabaseManager, private val serviceNotifications: MeshServiceNotifications, private val messageProcessor: Lazy, -) { +) : MeshActionHandler { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope } @@ -72,7 +79,7 @@ constructor( private const val EMOJI_INDICATOR = 1 } - fun onServiceAction(action: ServiceAction) { + override fun onServiceAction(action: ServiceAction) { ignoreException { val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException when (action) { @@ -102,7 +109,7 @@ constructor( AdminMessage(set_favorite_node = node.num) } } - nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite } + nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } } private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) { @@ -115,14 +122,14 @@ constructor( AdminMessage(remove_ignored_node = node.num) } } - nodeManager.updateNodeInfo(node.num) { it.isIgnored = newIgnoredStatus } + nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) } scope.handledLaunch { packetRepository.get().updateFilteredBySender(node.user.id, newIgnoredStatus) } } private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) { val node = action.node commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) } - nodeManager.updateNodeInfo(node.num) { it.isMuted = !node.isMuted } + nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } } private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) { @@ -147,7 +154,7 @@ constructor( val verifiedContact = action.contact.copy(manually_verified = true) commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) } nodeManager.handleReceivedUser( - verifiedContact.node_num ?: 0, + verifiedContact.node_num, verifiedContact.user ?: User(), manuallyVerified = true, ) @@ -155,11 +162,11 @@ constructor( private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) { scope.handledLaunch { + val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId()) val reaction = - ReactionEntity( - myNodeNum = myNodeNum, + Reaction( replyId = action.replyId, - userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL, + user = user, emoji = action.emoji, timestamp = nowMillis, snr = 0f, @@ -170,25 +177,25 @@ constructor( to = action.contactKey.substring(1), channel = action.contactKey[0].digitToInt(), ) - packetRepository.get().insertReaction(reaction) + packetRepository.get().insertReaction(reaction, myNodeNum) } } - fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) { + override fun handleSetOwner(u: MeshUser, myNodeNum: Int) { val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed) commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) } nodeManager.handleReceivedUser(myNodeNum, newUser) } - fun handleSend(p: DataPacket, myNodeNum: Int) { + override fun handleSend(p: DataPacket, myNodeNum: Int) { commandSender.sendData(p) - serviceBroadcasts.broadcastMessageStatus(p) - dataHandler.rememberDataPacket(p, myNodeNum, false) + serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) + dataHandler.get().rememberDataPacket(p, myNodeNum, false) val bytes = p.bytes ?: okio.ByteString.EMPTY analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) } - fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { + override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { if (destNum != myNodeNum) { val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum) val currentPosition = @@ -201,32 +208,32 @@ constructor( } } - fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) { + override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) { nodeManager.removeByNodenum(nodeNum) commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) } } - fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) { + override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) { val u = User.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) } nodeManager.handleReceivedUser(destNum, u) } - fun handleGetRemoteOwner(id: Int, destNum: Int) { + override fun handleGetRemoteOwner(id: Int, destNum: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) } } - fun handleSetConfig(payload: ByteArray, myNodeNum: Int) { + override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) { val c = Config.ADAPTER.decode(payload) commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) } } - fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { + override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { val c = Config.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) } } - fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { + override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) { AdminMessage(get_device_metadata_request = true) @@ -236,104 +243,104 @@ constructor( } } - fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) { + override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) { val c = ModuleConfig.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) } c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } } - fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) { + override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config)) } } - fun handleSetRingtone(destNum: Int, ringtone: String) { + override fun handleSetRingtone(destNum: Int, ringtone: String) { commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) } } - fun handleGetRingtone(id: Int, destNum: Int) { + override fun handleGetRingtone(id: Int, destNum: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) } } - fun handleSetCannedMessages(destNum: Int, messages: String) { + override fun handleSetCannedMessages(destNum: Int, messages: String) { commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) } } - fun handleGetCannedMessages(id: Int, destNum: Int) { + override fun handleGetCannedMessages(id: Int, destNum: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_canned_message_module_messages_request = true) } } - fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) { + override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) { if (payload != null) { val c = Channel.ADAPTER.decode(payload) commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) } } } - fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) { + override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) { if (payload != null) { val c = Channel.ADAPTER.decode(payload) commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) } } } - fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) { + override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) { commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) } } - fun handleRequestNeighborInfo(requestId: Int, destNum: Int) { + override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) { commandSender.requestNeighborInfo(requestId, destNum) } - fun handleBeginEditSettings(destNum: Int) { + override fun handleBeginEditSettings(destNum: Int) { commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } } - fun handleCommitEditSettings(destNum: Int) { + override fun handleCommitEditSettings(destNum: Int) { commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } } - fun handleRebootToDfu(destNum: Int) { + override fun handleRebootToDfu(destNum: Int) { commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) } } - fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) { + override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) { commandSender.requestTelemetry(requestId, destNum, type) } - fun handleRequestShutdown(requestId: Int, destNum: Int) { + override fun handleRequestShutdown(requestId: Int, destNum: Int) { commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) } } - fun handleRequestReboot(requestId: Int, destNum: Int) { + override fun handleRequestReboot(requestId: Int, destNum: Int) { commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) } } - fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { + override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA val otaEvent = AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY) commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) } } - fun handleRequestFactoryReset(requestId: Int, destNum: Int) { + override fun handleRequestFactoryReset(requestId: Int, destNum: Int) { commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) } } - fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) { + override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) { commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) } } - fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) { + override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) { commandSender.sendAdmin(destNum, requestId, wantResponse = true) { AdminMessage(get_device_connection_status_request = true) } } - fun handleUpdateLastAddress(deviceAddr: String?) { + override fun handleUpdateLastAddress(deviceAddr: String?) { val currentAddr = meshPrefs.deviceAddress if (deviceAddr != currentAddr) { meshPrefs.deviceAddress = deviceAddr diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt similarity index 73% rename from app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index 1d666ca2d4..86026b9be1 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -14,64 +14,71 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.MetadataEntity -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.MeshConfigFlowManager +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.ToRadio import java.io.IOException import javax.inject.Inject import javax.inject.Singleton +import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo +import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @Singleton -class MeshConfigFlowManager +class MeshConfigFlowManagerImpl @Inject constructor( - private val nodeManager: MeshNodeManager, - private val connectionManager: MeshConnectionManager, + private val nodeManager: NodeManager, + private val connectionManager: Lazy, private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, - private val connectionStateHolder: ConnectionStateHandler, - private val serviceBroadcasts: MeshServiceBroadcasts, + private val serviceRepository: ServiceRepository, + private val serviceBroadcasts: ServiceBroadcasts, private val analytics: PlatformAnalytics, - private val commandSender: MeshCommandSender, + private val commandSender: CommandSender, private val packetHandler: PacketHandler, -) { +) : MeshConfigFlowManager { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val configOnlyNonce = 69420 private val nodeInfoNonce = 69421 private val wantConfigDelay = 100L - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope } private val newNodes = mutableListOf() - val newNodeCount: Int + override val newNodeCount: Int get() = newNodes.size - private var rawMyNodeInfo: MyNodeInfo? = null + private var rawMyNodeInfo: ProtoMyNodeInfo? = null private var lastMetadata: DeviceMetadata? = null - private var newMyNodeInfo: MyNodeEntity? = null - private var myNodeInfo: MyNodeEntity? = null + private var newMyNodeInfo: SharedMyNodeInfo? = null + private var myNodeInfo: SharedMyNodeInfo? = null - fun handleConfigComplete(configCompleteId: Int) { + override fun handleConfigComplete(configCompleteId: Int) { when (configCompleteId) { configOnlyNonce -> handleConfigOnlyComplete() nodeInfoNonce -> handleNodeInfoComplete() @@ -94,7 +101,7 @@ constructor( } else { myNodeInfo = finalizedInfo Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" } - connectionManager.onRadioConfigLoaded() + connectionManager.get().onRadioConfigLoaded() } scope.handledLaunch { @@ -102,7 +109,7 @@ constructor( sendHeartbeat() delay(wantConfigDelay) Logger.i { "Requesting NodeInfo (Stage 2)" } - connectionManager.startNodeInfoOnly() + connectionManager.get().startNodeInfoOnly() } } @@ -129,19 +136,19 @@ constructor( nodeRepository.installConfig(it, entities) sendAnalytics(it) } - nodeManager.isNodeDbReady.value = true - nodeManager.allowNodeDbWrites.value = true - connectionStateHolder.setState(ConnectionState.Connected) + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + serviceRepository.setConnectionState(ConnectionState.Connected) serviceBroadcasts.broadcastConnection() - connectionManager.onNodeDbReady() + connectionManager.get().onNodeDbReady() } } - private fun sendAnalytics(mi: MyNodeEntity) { + private fun sendAnalytics(mi: SharedMyNodeInfo) { analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown") } - fun handleMyInfo(myInfo: MyNodeInfo) { + override fun handleMyInfo(myInfo: ProtoMyNodeInfo) { Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" } rawMyNodeInfo = myInfo nodeManager.myNodeNum = myInfo.my_node_num @@ -154,24 +161,29 @@ constructor( } } - fun handleLocalMetadata(metadata: DeviceMetadata) { + override fun handleLocalMetadata(metadata: DeviceMetadata) { Logger.i { "Local Metadata received: ${metadata.firmware_version}" } lastMetadata = metadata regenMyNodeInfo(metadata) } - fun handleNodeInfo(info: NodeInfo) { + override fun handleNodeInfo(info: NodeInfo) { newNodes.add(info) } + override fun triggerWantConfig() { + connectionManager.get().startConfigOnly() + } + private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) { val myInfo = rawMyNodeInfo if (myInfo != null) { try { val mi = with(myInfo) { - MyNodeEntity( - myNodeNum = my_node_num ?: 0, + SharedMyNodeInfo( + myNodeNum = my_node_num, + hasGPS = false, model = when (val hwModel = metadata?.hw_model) { null, @@ -187,12 +199,14 @@ constructor( minAppVersion = min_app_version, maxChannels = 8, hasWifi = metadata?.hasWifi == true, + channelUtilization = 0f, + airUtilTx = 0f, deviceId = device_id.utf8(), pioEnv = myInfo.pio_env.ifEmpty { null }, ) } if (metadata != null && metadata != DeviceMetadata()) { - scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) } + scope.handledLaunch { nodeRepository.insertMetadata(mi.myNodeNum, metadata) } } newMyNodeInfo = mi Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt similarity index 79% rename from app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index 616529d145..d5ff324266 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,8 +24,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig @@ -35,34 +37,33 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class MeshConfigHandler +class MeshConfigHandlerImpl @Inject constructor( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, - private val nodeManager: MeshNodeManager, -) { + private val nodeManager: NodeManager, +) : MeshConfigHandler { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val _localConfig = MutableStateFlow(LocalConfig()) - val localConfig = _localConfig.asStateFlow() + override val localConfig = _localConfig.asStateFlow() private val _moduleConfig = MutableStateFlow(LocalModuleConfig()) - val moduleConfig = _moduleConfig.asStateFlow() + override val moduleConfig = _moduleConfig.asStateFlow() - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope) - radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) } - fun handleDeviceConfig(config: Config) { + override fun handleDeviceConfig(config: Config) { scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } serviceRepository.setConnectionProgress("Device config received") } - fun handleModuleConfig(config: ModuleConfig) { + override fun handleModuleConfig(config: ModuleConfig) { scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } serviceRepository.setConnectionProgress("Module config received") @@ -71,13 +72,13 @@ constructor( } } - fun handleChannel(ch: Channel) { + override fun handleChannel(channel: Channel) { // We always want to save channel settings we receive from the radio - scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } + scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } // Update status message if we have node info, otherwise use a generic one val mi = nodeManager.getMyNodeInfo() - val index = ch.index ?: 0 + val index = channel.index if (mi != null) { serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})") } else { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt similarity index 79% rename from app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index eeb4882dc4..a420793dfc 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -14,19 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service - -import android.app.Notification -import android.content.Context -import androidx.glance.appwidget.updateAll -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.workDataOf +package org.meshtastic.core.data.manager + import co.touchlab.kermit.Logger -import com.geeksville.mesh.repository.radio.RadioInterfaceService -import com.geeksville.mesh.widget.LocalStatsWidget -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -43,12 +33,25 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.prefs.ui.UiPrefs +import org.meshtastic.core.repository.AppWidgetUpdater +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshWorkerManager +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting @@ -56,8 +59,6 @@ import org.meshtastic.core.resources.device_sleeping import org.meshtastic.core.resources.disconnected import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.meshtastic_app_name -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Config import org.meshtastic.proto.Telemetry @@ -70,27 +71,27 @@ import kotlin.time.DurationUnit @Suppress("LongParameterList", "TooManyFunctions") @Singleton -class MeshConnectionManager +class MeshConnectionManagerImpl @Inject constructor( - @ApplicationContext private val context: Context, private val radioInterfaceService: RadioInterfaceService, - private val connectionStateHolder: ConnectionStateHandler, - private val serviceBroadcasts: MeshServiceBroadcasts, + private val serviceRepository: ServiceRepository, + private val serviceBroadcasts: ServiceBroadcasts, private val serviceNotifications: MeshServiceNotifications, private val uiPrefs: UiPrefs, private val packetHandler: PacketHandler, private val nodeRepository: NodeRepository, private val locationManager: MeshLocationManager, - private val mqttManager: MeshMqttManager, - private val historyManager: MeshHistoryManager, + private val mqttManager: MqttManager, + private val historyManager: HistoryManager, private val radioConfigRepository: RadioConfigRepository, - private val commandSender: MeshCommandSender, - private val nodeManager: MeshNodeManager, + private val commandSender: CommandSender, + private val nodeManager: NodeManager, private val analytics: PlatformAnalytics, private val packetRepository: PacketRepository, - private val workManager: WorkManager, -) { + private val workerManager: MeshWorkerManager, + private val appWidgetUpdater: AppWidgetUpdater, +) : MeshConnectionManager { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var sleepTimeout: Job? = null private var locationRequestsJob: Job? = null @@ -98,18 +99,16 @@ constructor( private var connectTimeMsec = 0L @OptIn(FlowPreview::class) - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) // Ensure notification title and content stay in sync with state changes - connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope) + serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope) - // Kickstart the widget composition. The widget internally uses collectAsState() - // and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation. scope.launch { try { - LocalStatsWidget().updateAll(context) + appWidgetUpdater.updateAll() } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { Logger.e(e) { "Failed to kickstart LocalStatsWidget" } } @@ -154,7 +153,7 @@ constructor( } private fun onConnectionChanged(c: ConnectionState) { - val current = connectionStateHolder.connectionState.value + val current = serviceRepository.connectionState.value if (current == c) return // If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting) @@ -171,7 +170,7 @@ constructor( handshakeTimeout = null when (c) { - is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting) + is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting) is ConnectionState.Connected -> handleConnected() is ConnectionState.DeviceSleep -> handleDeviceSleep() is ConnectionState.Disconnected -> handleDisconnected() @@ -180,8 +179,8 @@ constructor( private fun handleConnected() { // The service state remains 'Connecting' until config is fully loaded - if (connectionStateHolder.connectionState.value != ConnectionState.Connected) { - connectionStateHolder.setState(ConnectionState.Connecting) + if (serviceRepository.connectionState.value != ConnectionState.Connected) { + serviceRepository.setConnectionState(ConnectionState.Connecting) } serviceBroadcasts.broadcastConnection() Logger.i { "Starting mesh handshake (Stage 1)" } @@ -192,12 +191,12 @@ constructor( handshakeTimeout = scope.handledLaunch { delay(HANDSHAKE_TIMEOUT) - if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) { + if (serviceRepository.connectionState.value is ConnectionState.Connecting) { Logger.w { "Handshake stall detected! Retrying Stage 1." } startConfigOnly() // Recursive timeout for one more try delay(HANDSHAKE_TIMEOUT) - if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) { + if (serviceRepository.connectionState.value is ConnectionState.Connecting) { Logger.e { "Handshake still stalled after retry. Resetting connection." } onConnectionChanged(ConnectionState.Disconnected) } @@ -206,7 +205,7 @@ constructor( } private fun handleDeviceSleep() { - connectionStateHolder.setState(ConnectionState.DeviceSleep) + serviceRepository.setConnectionState(ConnectionState.DeviceSleep) packetHandler.stopPacketQueue() locationManager.stop() mqttManager.stop() @@ -239,7 +238,7 @@ constructor( } private fun handleDisconnected() { - connectionStateHolder.setState(ConnectionState.Disconnected) + serviceRepository.setConnectionState(ConnectionState.Disconnected) packetHandler.stopPacketQueue() locationManager.stop() mqttManager.stop() @@ -254,29 +253,20 @@ constructor( serviceBroadcasts.broadcastConnection() } - fun startConfigOnly() { + override fun startConfigOnly() { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) } - fun startNodeInfoOnly() { + override fun startNodeInfoOnly() { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) } - fun onRadioConfigLoaded() { + override fun onRadioConfigLoaded() { scope.handledLaunch { val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList() queuedPackets.forEach { packet -> try { - val workRequest = - OneTimeWorkRequestBuilder() - .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packet.id)) - .build() - - workManager.enqueueUniqueWork( - "${SendMessageWorker.WORK_NAME_PREFIX}${packet.id}", - ExistingWorkPolicy.REPLACE, - workRequest, - ) + workerManager.enqueueSendMessage(packet.id) } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { Logger.e(e) { "Failed to enqueue queued packet worker" } } @@ -288,7 +278,7 @@ constructor( commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) } } - fun onNodeDbReady() { + override fun onNodeDbReady() { handshakeTimeout?.cancel() handshakeTimeout = null @@ -329,14 +319,14 @@ constructor( ) } - fun updateTelemetry(telemetry: Telemetry) { - telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) } - updateStatusNotification(telemetry) + override fun updateTelemetry(t: Telemetry) { + t.local_stats?.let { nodeRepository.updateLocalStats(it) } + updateStatusNotification(t) } - fun updateStatusNotification(telemetry: Telemetry? = null): Notification { + override fun updateStatusNotification(telemetry: Telemetry?): Any { val summary = - when (connectionStateHolder.connectionState.value) { + when (serviceRepository.connectionState.value) { is ConnectionState.Connected -> getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected) is ConnectionState.Disconnected -> getString(Res.string.disconnected) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt similarity index 81% rename from app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 36338d4934..e84af354c7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -14,13 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager -import android.util.Log import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity -import com.geeksville.mesh.BuildConfig -import com.geeksville.mesh.repository.radio.InterfaceId import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -33,25 +30,36 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.util.MeshDataMapper import org.meshtastic.core.model.util.SfppHasher import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toOneLiner -import org.meshtastic.core.prefs.mesh.MeshPrefs +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.MeshConfigFlowManager +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MessageFilter +import org.meshtastic.core.repository.NeighborInfoHandler +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.critical_alert import org.meshtastic.core.resources.error_duty_cycle import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.unknown_username import org.meshtastic.core.resources.waypoint_received -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.core.service.filter.MessageFilterService import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Paxcount @@ -70,33 +78,42 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds +/** + * Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets. + * + * This class handles the complexity of: + * 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects. + * 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, SFPP). + * 3. Managing message history and persistence. + * 4. Triggering notifications for various packet types (Text, Waypoints, Battery). + * 5. Tracking received telemetry for node updates. + */ @Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod") @Singleton -class MeshDataHandler +class MeshDataHandlerImpl @Inject constructor( - private val nodeManager: MeshNodeManager, + private val nodeManager: NodeManager, private val packetHandler: PacketHandler, private val serviceRepository: ServiceRepository, private val packetRepository: Lazy, - private val serviceBroadcasts: MeshServiceBroadcasts, + private val serviceBroadcasts: ServiceBroadcasts, private val serviceNotifications: MeshServiceNotifications, private val analytics: PlatformAnalytics, private val dataMapper: MeshDataMapper, - private val configHandler: MeshConfigHandler, - private val configFlowManager: MeshConfigFlowManager, - private val commandSender: MeshCommandSender, - private val historyManager: MeshHistoryManager, - private val meshPrefs: MeshPrefs, - private val connectionManager: MeshConnectionManager, - private val tracerouteHandler: MeshTracerouteHandler, - private val neighborInfoHandler: MeshNeighborInfoHandler, + private val configHandler: Lazy, + private val configFlowManager: Lazy, + private val commandSender: CommandSender, + private val historyManager: HistoryManager, + private val connectionManager: Lazy, + private val tracerouteHandler: TracerouteHandler, + private val neighborInfoHandler: NeighborInfoHandler, private val radioConfigRepository: RadioConfigRepository, - private val messageFilterService: MessageFilterService, -) { + private val messageFilter: MessageFilter, +) : MeshDataHandler { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope } @@ -108,7 +125,7 @@ constructor( PortNum.NODE_STATUS_APP.value, ) - fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) { + override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) { val dataPacket = dataMapper.toDataPacket(packet) ?: return val fromUs = myNodeNum == packet.from dataPacket.status = MessageStatus.RECEIVED @@ -221,7 +238,7 @@ constructor( handleReceivedStoreAndForward(dataPacket, u, myNodeNum) } - @Suppress("LongMethod") + @Suppress("LongMethod", "ReturnCount") private fun handleStoreForwardPlusPlus(packet: MeshPacket) { val payload = packet.decoded?.payload ?: return val sfpp = @@ -340,20 +357,20 @@ constructor( val fromNum = packet.from u.get_module_config_response?.let { config -> if (fromNum == myNodeNum) { - configHandler.handleModuleConfig(config) + configHandler.get().handleModuleConfig(config) } else { config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) } } } if (fromNum == myNodeNum) { - u.get_config_response?.let { configHandler.handleDeviceConfig(it) } - u.get_channel_response?.let { configHandler.handleChannel(it) } + u.get_config_response?.let { configHandler.get().handleDeviceConfig(it) } + u.get_channel_response?.let { configHandler.get().handleChannel(it) } } u.get_device_metadata_response?.let { metadata -> if (fromNum == myNodeNum) { - configFlowManager.handleLocalMetadata(metadata) + configFlowManager.get().handleLocalMetadata(metadata) } else { nodeManager.insertMetadata(fromNum, metadata) } @@ -395,39 +412,43 @@ constructor( val fromNum = packet.from val isRemote = (fromNum != myNodeNum) if (!isRemote) { - connectionManager.updateTelemetry(t) + connectionManager.get().updateTelemetry(t) } - nodeManager.updateNodeInfo(fromNum) { nodeEntity -> + nodeManager.updateNode(fromNum) { node: Node -> val metrics = t.device_metrics val environment = t.environment_metrics val power = t.power_metrics + + var nextNode = node when { metrics != null -> { - nodeEntity.deviceTelemetry = t - if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) { + nextNode = nextNode.copy(deviceMetrics = metrics) + if (fromNum == myNodeNum || (isRemote && node.isFavorite)) { if ( (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED && (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD ) { if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) { - serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote) + serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote) } } else { if (batteryPercentCooldowns.containsKey(fromNum)) { batteryPercentCooldowns.remove(fromNum) } - serviceNotifications.cancelLowBatteryNotification(nodeEntity) + serviceNotifications.cancelLowBatteryNotification(nextNode) } } } - environment != null -> nodeEntity.environmentTelemetry = t - power != null -> nodeEntity.powerTelemetry = t + environment != null -> nextNode = nextNode.copy(environmentMetrics = environment) + power != null -> nextNode = nextNode.copy(powerMetrics = power) } + nextNode } } + @Suppress("ReturnCount") private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean { val isRemote = (fromNum != myNodeNum) var shouldDisplay = false @@ -475,30 +496,26 @@ constructor( private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) { scope.handledLaunch { val isAck = routingError == Routing.Error.NONE.value - val p = packetRepository.get().getPacketById(requestId) + val p = packetRepository.get().getPacketByPacketId(requestId) val reaction = packetRepository.get().getReactionByPacketId(requestId) @Suppress("MaxLineLength") Logger.d { - val statusInfo = "status=${p?.data?.status ?: reaction?.status}" + val statusInfo = "status=${p?.status ?: reaction?.status}" "[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " + - "packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo" + "packetId=${p?.id ?: reaction?.packetId} dataId=${p?.id} $statusInfo" } val m = when { - isAck && (fromId == p?.data?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED + isAck && (fromId == p?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED isAck -> MessageStatus.DELIVERED else -> MessageStatus.ERROR } - if (p != null && p.data.status != MessageStatus.RECEIVED) { - p.data.status = m - p.routingError = routingError - if (isAck) { - p.data.relays += 1 - } - p.data.relayNode = relayNode - packetRepository.get().update(p) + if (p != null && p.status != MessageStatus.RECEIVED) { + val updatedPacket = + p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode) + packetRepository.get().update(updatedPacket) } reaction?.let { r -> @@ -517,11 +534,11 @@ constructor( private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { Logger.d { "StoreAndForward: variant from ${dataPacket.from}" } - val transport = currentTransport() + // For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it. + // In the original, it was used for logging. val h = s.history val lastRequest = h?.last_request ?: 0 - val baseContext = "transport=$transport from=${dataPacket.from}" - historyLog { "rxStoreForward $baseContext lastRequest=$lastRequest" } + Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" } when { s.stats != null -> { val text = s.stats.toString() @@ -533,10 +550,6 @@ constructor( rememberDataPacket(u, myNodeNum) } h != null -> { - @Suppress("MaxLineLength") - historyLog(Log.DEBUG) { - "routerHistory $baseContext messages=${h.history_messages} window=${h.window} lastReq=${h.last_request}" - } val text = "Total messages: ${h.history_messages}\n" + "History window: ${h.window.milliseconds.inWholeMinutes} min\n" + @@ -547,20 +560,17 @@ constructor( dataType = PortNum.TEXT_MESSAGE_APP.value, ) rememberDataPacket(u, myNodeNum) - historyManager.updateStoreForwardLastRequest("router_history", h.last_request, transport) + // historyManager call remains same + historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown") } s.heartbeat != null -> { val hb = s.heartbeat!! - historyLog { "rxHeartbeat $baseContext period=${hb.period} secondary=${hb.secondary}" } + Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" } } s.text != null -> { if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { dataPacket.to = DataPacket.ID_BROADCAST } - @Suppress("MaxLineLength") - historyLog(Log.DEBUG) { - "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} to=${dataPacket.to} decision=remember" - } val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value) rememberDataPacket(u, myNodeNum) } @@ -568,7 +578,7 @@ constructor( } } - fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) { + override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum) @@ -594,25 +604,16 @@ constructor( // 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 = nowMillis, - read = fromLocal || isFiltered, - data = dataPacket, - snr = dataPacket.snr, - rssi = dataPacket.rssi, - hopsAway = dataPacket.hopsAway, - filtered = isFiltered, - ) - - insert(packetToSave) + insert( + dataPacket, + myNodeNum, + contactKey, + nowMillis, + read = fromLocal || isFiltered, + filtered = isFiltered, + ) if (!isFiltered) { - handlePacketNotification(packetToSave, dataPacket, contactKey, updateNotification) + handlePacketNotification(dataPacket, contactKey, updateNotification) } } } @@ -625,11 +626,10 @@ constructor( if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled - return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled) + return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled) } private suspend fun handlePacketNotification( - packet: Packet, dataPacket: DataPacket, contactKey: String, updateNotification: Boolean, @@ -637,7 +637,7 @@ constructor( val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true val isSilent = conversationMuted || nodeMuted - if (packet.port_num == PortNum.ALERT_APP.value && !isSilent) { + if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { serviceNotifications.showAlertNotification( contactKey, getSenderName(dataPacket), @@ -696,13 +696,14 @@ constructor( val decoded = packet.decoded ?: return@handledLaunch val emoji = decoded.payload.toByteArray().decodeToString() val fromId = nodeManager.toNodeID(packet.from) - val toId = nodeManager.toNodeID(packet.to) + + val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from) + val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to) val reaction = - ReactionEntity( - myNodeNum = nodeManager.myNodeNum ?: 0, + Reaction( replyId = decoded.reply_id, - userId = fromId, + user = fromNode.user, emoji = emoji, timestamp = nowMillis, snr = packet.rx_snr, @@ -715,7 +716,7 @@ constructor( }, packetId = packet.id, status = MessageStatus.RECEIVED, - to = toId, + to = toNode.user.id, channel = packet.channel, ) @@ -729,25 +730,25 @@ constructor( return@handledLaunch } - packetRepository.get().insertReaction(reaction) + packetRepository.get().insertReaction(reaction, nodeManager.myNodeNum ?: 0) // Find the original packet to get the contactKey - packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { original -> + packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { originalPacket -> // Skip notification if the original message was filtered - if (original.packet.filtered) return@let - - val contactKey = original.packet.contact_key + val targetId = + if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from + val contactKey = "${originalPacket.channel}$targetId" val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true val isSilent = conversationMuted || nodeMuted if (!isSilent) { val channelName = - if (original.packet.data.to == DataPacket.ID_BROADCAST) { + if (originalPacket.to == DataPacket.ID_BROADCAST) { radioConfigRepository.channelSetFlow .first() .settings - .getOrNull(original.packet.data.channel) + .getOrNull(originalPacket.channel) ?.name } else { null @@ -756,7 +757,7 @@ constructor( contactKey, getSenderName(dataMapper.toDataPacket(packet)!!), emoji, - original.packet.data.to == DataPacket.ID_BROADCAST, + originalPacket.to == DataPacket.ID_BROADCAST, channelName, isSilent, ) @@ -764,33 +765,6 @@ constructor( } } - private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) { - InterfaceId.BLUETOOTH.id -> "BLE" - InterfaceId.TCP.id -> "TCP" - InterfaceId.SERIAL.id -> "Serial" - InterfaceId.MOCK.id -> "Mock" - InterfaceId.NOP.id -> "NOP" - else -> "Unknown" - } - - private inline fun historyLog( - priority: Int = Log.INFO, - throwable: Throwable? = null, - crossinline message: () -> String, - ) { - if (!BuildConfig.DEBUG) return - val logger = Logger.withTag("HistoryReplay") - val msg = message() - when (priority) { - Log.VERBOSE -> logger.v(throwable) { msg } - Log.DEBUG -> logger.d(throwable) { msg } - Log.INFO -> logger.i(throwable) { msg } - Log.WARN -> logger.w(throwable) { msg } - Log.ERROR -> logger.e(throwable) { msg } - else -> logger.i(throwable) { msg } - } - } - companion object { private const val HOPS_AWAY_UNAVAILABLE = -1 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt similarity index 75% rename from app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 7ed7980c35..1c19c8f317 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -14,11 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager -import android.util.Log import co.touchlab.kermit.Logger -import com.geeksville.mesh.BuildConfig import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -31,8 +29,13 @@ import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.database.entity.MeshLog +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.isLora -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.FromRadioPacketHandler +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.FromRadio import org.meshtastic.proto.LogRecord import org.meshtastic.proto.MeshPacket @@ -44,17 +47,18 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.uuid.Uuid +/** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */ @Suppress("TooManyFunctions") @Singleton -class MeshMessageProcessor +class MeshMessageProcessorImpl @Inject constructor( - private val nodeManager: MeshNodeManager, + private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, private val meshLogRepository: Lazy, - private val router: MeshRouter, + private val router: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, -) { +) : MeshMessageProcessor { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val logUuidByPacketId = ConcurrentHashMap() private val logInsertJobByPacketId = ConcurrentHashMap() @@ -62,11 +66,11 @@ constructor( private val earlyReceivedPackets = ArrayDeque() private val maxEarlyPacketBuffer = 10240 - fun clearEarlyPackets() { + override fun clearEarlyPackets() { synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() } } - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope nodeManager.isNodeDbReady .onEach { ready -> @@ -77,7 +81,7 @@ constructor( .launchIn(scope) } - fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) { + override fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) { runCatching { FromRadio.ADAPTER.decode(bytes) } .onSuccess { proto -> processFromRadio(proto, myNodeNum) } .onFailure { primaryException -> @@ -134,7 +138,7 @@ constructor( ) } - fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { + override fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { val rxTime = if (packet.rx_time == 0) { nowSeconds.toInt() @@ -149,21 +153,9 @@ constructor( synchronized(earlyReceivedPackets) { val queueSize = earlyReceivedPackets.size if (queueSize >= maxEarlyPacketBuffer) { - val dropped = earlyReceivedPackets.removeFirst() - historyLog(Log.WARN) { - val portLabel = - dropped.decoded?.portnum?.name ?: dropped.decoded?.portnum?.value?.toString() ?: "unknown" - "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel" - } + earlyReceivedPackets.removeFirst() } earlyReceivedPackets.addLast(preparedPacket) - val portLabel = - preparedPacket.decoded?.portnum?.name - ?: preparedPacket.decoded?.portnum?.value?.toString() - ?: "unknown" - historyLog { - "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel" - } } } } @@ -176,11 +168,12 @@ constructor( earlyReceivedPackets.clear() list } - historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" } + Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" } val myNodeNum = nodeManager.myNodeNum packets.forEach { processReceivedMeshPacket(it, myNodeNum) } } + @Suppress("LongMethod") private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { val decoded = packet.decoded ?: return val log = @@ -202,22 +195,24 @@ constructor( myNodeNum?.let { myNum -> val from = packet.from val isOtherNode = myNum != from - nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) { it.lastHeard = nowSeconds.toInt() } - nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) { - it.lastHeard = packet.rx_time - it.viaMqtt = packet.via_mqtt == true - it.lastTransport = packet.transport_mechanism.value - + nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node -> + node.copy(lastHeard = nowSeconds.toInt()) + } + nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node -> + val viaMqtt = packet.via_mqtt == true val isDirect = packet.hop_start == packet.hop_limit - if (isDirect && packet.isLora() && !it.viaMqtt) { - it.snr = packet.rx_snr - it.rssi = packet.rx_rssi + + var snr = node.snr + var rssi = node.rssi + if (isDirect && packet.isLora() && !viaMqtt) { + snr = packet.rx_snr + rssi = packet.rx_rssi } - it.hopsAway = + val hopsAway = if (decoded.portnum == PortNum.RANGE_TEST_APP) { 0 - } else if (it.viaMqtt) { + } else if (viaMqtt) { -1 } else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) { -1 @@ -226,10 +221,19 @@ constructor( } else { packet.hop_start - packet.hop_limit } + + node.copy( + lastHeard = packet.rx_time, + viaMqtt = viaMqtt, + lastTransport = packet.transport_mechanism.value, + snr = snr, + rssi = rssi, + hopsAway = hopsAway, + ) } try { - router.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob) + router.get().dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob) } finally { logUuidByPacketId.remove(packet.id) logInsertJobByPacketId.remove(packet.id) @@ -239,24 +243,6 @@ constructor( private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) } - private inline fun historyLog( - priority: Int = Log.INFO, - throwable: Throwable? = null, - crossinline message: () -> String, - ) { - if (!BuildConfig.DEBUG) return - val logger = Logger.withTag("HistoryReplay") - val msg = message() - when (priority) { - Log.VERBOSE -> logger.v(throwable) { msg } - Log.DEBUG -> logger.d(throwable) { msg } - Log.INFO -> logger.i(throwable) { msg } - Log.WARN -> logger.w(throwable) { msg } - Log.ERROR -> logger.e(throwable) { msg } - else -> logger.i(throwable) { msg } - } - } - private fun ByteArray.toHexString(): String = this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt new file mode 100644 index 0000000000..b079b1d868 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt @@ -0,0 +1,75 @@ +/* + * 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.data.manager + +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.core.repository.MeshActionHandler +import org.meshtastic.core.repository.MeshConfigFlowManager +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.NeighborInfoHandler +import org.meshtastic.core.repository.TracerouteHandler +import javax.inject.Inject +import javax.inject.Singleton + +/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */ +@Suppress("LongParameterList") +@Singleton +class MeshRouterImpl +@Inject +constructor( + private val dataHandlerLazy: Lazy, + private val configHandlerLazy: Lazy, + private val tracerouteHandlerLazy: Lazy, + private val neighborInfoHandlerLazy: Lazy, + private val configFlowManagerLazy: Lazy, + private val mqttManagerLazy: Lazy, + private val actionHandlerLazy: Lazy, +) : MeshRouter { + override val dataHandler: MeshDataHandler + get() = dataHandlerLazy.get() + + override val configHandler: MeshConfigHandler + get() = configHandlerLazy.get() + + override val tracerouteHandler: TracerouteHandler + get() = tracerouteHandlerLazy.get() + + override val neighborInfoHandler: NeighborInfoHandler + get() = neighborInfoHandlerLazy.get() + + override val configFlowManager: MeshConfigFlowManager + get() = configFlowManagerLazy.get() + + override val mqttManager: MqttManager + get() = mqttManagerLazy.get() + + override val actionHandler: MeshActionHandler + get() = actionHandlerLazy.get() + + override fun start(scope: CoroutineScope) { + dataHandler.start(scope) + configHandler.start(scope) + tracerouteHandler.start(scope) + neighborInfoHandler.start(scope) + configFlowManager.start(scope) + actionHandler.start(scope) + } +} diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt similarity index 69% rename from core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt index bb8a773aac..906e615ae9 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt @@ -14,34 +14,25 @@ * 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 +package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import org.meshtastic.core.prefs.filter.FilterPrefs +import org.meshtastic.core.repository.MessageFilter 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. - */ +/** Implementation of [MessageFilter] that uses regex and plain text matching. */ @Singleton -class MessageFilterService @Inject constructor(private val filterPrefs: FilterPrefs) { +class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs) : MessageFilter { 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 { + override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean { if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) { return false } @@ -49,11 +40,7 @@ class MessageFilterService @Inject constructor(private val filterPrefs: FilterPr 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() { + override fun rebuildPatterns() { compiledPatterns = filterPrefs.filterWords.mapNotNull { word -> try { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt similarity index 84% rename from app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index 314b7c99c1..7684ebd205 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -14,11 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity -import com.geeksville.mesh.repository.network.MQTTRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -26,24 +25,27 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.network.repository.MQTTRepository +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.ToRadio import javax.inject.Inject import javax.inject.Singleton @Singleton -class MeshMqttManager +class MqttManagerImpl @Inject constructor( private val mqttRepository: MQTTRepository, private val packetHandler: PacketHandler, private val serviceRepository: ServiceRepository, -) { +) : MqttManager { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var mqttMessageFlow: Job? = null - fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { + override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) { this.scope = scope if (mqttMessageFlow?.isActive == true) return if (enabled && proxyToClientEnabled) { @@ -60,7 +62,7 @@ constructor( } } - fun stop() { + override fun stop() { if (mqttMessageFlow?.isActive == true) { Logger.i { "Stopping MqttClientProxy" } mqttMessageFlow?.cancel() @@ -68,7 +70,7 @@ constructor( } } - fun handleMqttProxyMessage(message: MqttClientProxyMessage) { + override fun handleMqttProxyMessage(message: MqttClientProxyMessage) { val topic = message.topic ?: "" Logger.d { "[mqttClientProxyMessage] $topic" } val retained = message.retained == true diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt similarity index 77% rename from app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 3574bf6e1c..df19abacf6 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -14,17 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.unknown_username -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NeighborInfoHandler +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NeighborInfo import java.util.Locale @@ -32,21 +33,21 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class MeshNeighborInfoHandler +class NeighborInfoHandlerImpl @Inject constructor( - private val nodeManager: MeshNodeManager, + private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, - private val commandSender: MeshCommandSender, - private val serviceBroadcasts: MeshServiceBroadcasts, -) { + private val commandSender: CommandSender, + private val serviceBroadcasts: ServiceBroadcasts, +) : NeighborInfoHandler { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope } - fun handleNeighborInfo(packet: MeshPacket) { + override fun handleNeighborInfo(packet: MeshPacket) { val payload = packet.decoded?.payload ?: return val ni = NeighborInfo.ADAPTER.decode(payload) @@ -58,7 +59,7 @@ constructor( } // Update Node DB - nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) } + nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) } // Format for UI response val requestId = packet.decoded?.request_id ?: 0 @@ -67,11 +68,11 @@ constructor( val neighbors = ni.neighbors.joinToString("\n") { n -> val node = nodeManager.nodeDBbyNodeNum[n.node_id] - val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username) + val name = node?.let { "${it.user.long_name} (${it.user.short_name})" } ?: "Unknown" "• $name (SNR: ${n.snr})" } - val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.longName ?: "Unknown"}:\n$neighbors" + val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.user?.long_name ?: "Unknown"}:\n$neighbors" val responseText = if (start != null) { diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt new file mode 100644 index 0000000000..e9172809b0 --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -0,0 +1,316 @@ +/* + * 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.data.manager + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import okio.ByteString +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.DeviceMetrics +import org.meshtastic.core.model.EnvironmentMetrics +import org.meshtastic.core.model.MeshUser +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeInfo +import org.meshtastic.core.model.Position +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.StatusMessage +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition + +/** + * Implementation of [NodeManager] that maintains an in-memory database of the mesh. + * + * This component acts as the "brain" for node-related data during a connection session. It manages: + * 1. In-memory maps for fast node lookup by number or ID. + * 2. Synchronization of node data between the radio and the persistent database. + * 3. Processing of incoming node-related packets (User, Position, Telemetry). + * 4. Broadcasting changes to the rest of the application. + */ +@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") +@Singleton +class NodeManagerImpl +@Inject +constructor( + private val nodeRepository: NodeRepository, + private val serviceBroadcasts: ServiceBroadcasts, + private val serviceNotifications: MeshServiceNotifications, +) : NodeManager { + private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override val nodeDBbyNodeNum = ConcurrentHashMap() + override val nodeDBbyID = ConcurrentHashMap() + + override val isNodeDbReady = MutableStateFlow(false) + override val allowNodeDbWrites = MutableStateFlow(false) + + override fun setNodeDbReady(ready: Boolean) { + isNodeDbReady.value = ready + } + + override fun setAllowNodeDbWrites(allowed: Boolean) { + allowNodeDbWrites.value = allowed + } + + override var myNodeNum: Int? = null + + override fun start(scope: CoroutineScope) { + this.scope = scope + } + + companion object { + private const val TIME_MS_TO_S = 1000L + } + + override fun loadCachedNodeDB() { + scope.handledLaunch { + val nodes = nodeRepository.nodeDBbyNum.first() + nodeDBbyNodeNum.putAll(nodes) + nodes.values.forEach { nodeDBbyID[it.user.id] = it } + myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum + } + } + + override fun clear() { + nodeDBbyNodeNum.clear() + nodeDBbyID.clear() + isNodeDbReady.value = false + allowNodeDbWrites.value = false + myNodeNum = null + } + + override fun getMyNodeInfo(): MyNodeInfo? { + val mi = nodeRepository.myNodeInfo.value ?: return null + val myNode = nodeDBbyNodeNum[mi.myNodeNum] + return MyNodeInfo( + myNodeNum = mi.myNodeNum, + hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, + model = mi.model ?: myNode?.user?.hw_model?.name, + firmwareVersion = mi.firmwareVersion, + couldUpdate = mi.couldUpdate, + shouldUpdate = mi.shouldUpdate, + currentPacketId = mi.currentPacketId, + messageTimeoutMsec = mi.messageTimeoutMsec, + minAppVersion = mi.minAppVersion, + maxChannels = mi.maxChannels, + hasWifi = mi.hasWifi, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = mi.deviceId ?: myNode?.user?.id, + ) + } + + override fun getMyId(): String { + val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return "" + return nodeDBbyNodeNum[num]?.user?.id ?: "" + } + + override fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() } + + override fun removeByNodenum(nodeNum: Int) { + nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) } + } + + fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeDBbyNodeNum.getOrPut(n) { + val userId = DataPacket.nodeNumToDefaultId(n) + val defaultUser = + User( + id = userId, + long_name = "Meshtastic ${userId.takeLast(n = 4)}", + short_name = userId.takeLast(n = 4), + hw_model = HardwareModel.UNSET, + ) + + Node(num = n, user = defaultUser, channel = channel) + } + + override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { + val current = nodeDBbyNodeNum[nodeNum] ?: getOrCreateNode(nodeNum, channel) + val next = transform(current) + nodeDBbyNodeNum[nodeNum] = next + if (next.user.id.isNotEmpty()) { + nodeDBbyID[next.user.id] = next + } + + if (next.user.id.isNotEmpty() && isNodeDbReady.value) { + scope.handledLaunch { nodeRepository.upsert(next) } + } + + if (withBroadcast) { + serviceBroadcasts.broadcastNodeChange(next) + } + } + + override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { + updateNode(fromNum) { node -> + val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET) + val shouldPreserve = shouldPreserveExistingUser(node.user, p) + + val next = + if (shouldPreserve) { + node.copy(channel = channel, manuallyVerified = manuallyVerified) + } else { + val keyMatch = !node.hasPKC || node.user.public_key == p.public_key + val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) + node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified) + } + if (newNode && !shouldPreserve) { + serviceNotifications.showNewNodeSeenNotification(next) + } + next + } + } + + override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) { + if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) { + Logger.d { "Ignoring nop position update for the local node" } + } else { + updateNode(fromNum) { node -> + node.copy(position = p.copy(time = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt())) + } + } + } + + override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { + updateNode(fromNum) { node -> + when { + telemetry.device_metrics != null -> node.copy(deviceMetrics = telemetry.device_metrics!!) + telemetry.environment_metrics != null -> node.copy(environmentMetrics = telemetry.environment_metrics!!) + telemetry.power_metrics != null -> node.copy(powerMetrics = telemetry.power_metrics!!) + else -> node + } + } + } + + override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) { + updateNode(fromNum) { it.copy(paxcounter = p) } + } + + override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) { + updateNodeStatus(fromNum, s.status) + } + + override fun updateNodeStatus(nodeNum: Int, status: String?) { + updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) } + } + + override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) { + updateNode(info.num, withBroadcast = withBroadcast) { node -> + var next = node + val user = info.user + if (user != null) { + if (shouldPreserveExistingUser(node.user, user)) { + // keep existing names + } else { + var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it } + if (info.via_mqtt) { + newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") + } + next = next.copy(user = newUser) + } + } + val position = info.position + if (position != null) { + next = next.copy(position = position) + } + next = + next.copy( + lastHeard = info.last_heard, + deviceMetrics = info.device_metrics ?: next.deviceMetrics, + channel = info.channel, + viaMqtt = info.via_mqtt, + hopsAway = info.hops_away ?: -1, + isFavorite = info.is_favorite, + isIgnored = info.is_ignored, + isMuted = info.is_muted, + ) + next + } + } + + override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { + scope.handledLaunch { nodeRepository.insertMetadata(nodeNum, metadata) } + } + + private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { + val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET + val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET + return hasExistingUser && isDefaultName && isDefaultHwModel + } + + override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) + } + + private fun Node.toNodeInfo(): NodeInfo = NodeInfo( + num = num, + user = + MeshUser( + id = user.id, + longName = user.long_name, + shortName = user.short_name, + hwModel = user.hw_model, + role = user.role.value, + ), + position = + Position( + latitude = latitude, + longitude = longitude, + altitude = position.altitude ?: 0, + time = position.time, + satellitesInView = position.sats_in_view ?: 0, + groundSpeed = position.ground_speed ?: 0, + groundTrack = position.ground_track ?: 0, + precisionBits = position.precision_bits ?: 0, + ) + .takeIf { latitude != 0.0 || longitude != 0.0 }, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceMetrics = + DeviceMetrics( + batteryLevel = deviceMetrics.battery_level ?: 0, + voltage = deviceMetrics.voltage ?: 0f, + channelUtilization = deviceMetrics.channel_utilization ?: 0f, + airUtilTx = deviceMetrics.air_util_tx ?: 0f, + uptimeSeconds = deviceMetrics.uptime_seconds ?: 0, + ), + channel = channel, + environmentMetrics = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0), + hopsAway = hopsAway, + nodeStatus = nodeStatus, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt similarity index 77% rename from app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index d85edd7ad2..a29cfed986 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -14,10 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger -import com.geeksville.mesh.repository.radio.RadioInterfaceService import dagger.Lazy import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -30,13 +29,18 @@ import kotlinx.coroutines.withTimeoutOrNull import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.RadioNotConnectedException import org.meshtastic.core.model.util.toOneLineString import org.meshtastic.core.model.util.toPIIString +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.QueueStatus @@ -51,18 +55,18 @@ import kotlin.uuid.Uuid @Suppress("TooManyFunctions") @Singleton -class PacketHandler +class PacketHandlerImpl @Inject constructor( private val packetRepository: Lazy, - private val serviceBroadcasts: MeshServiceBroadcasts, + private val serviceBroadcasts: ServiceBroadcasts, private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, - private val connectionStateHolder: ConnectionStateHandler, -) { + private val serviceRepository: ServiceRepository, +) : PacketHandler { companion object { - private val TIMEOUT = 5.seconds // Increased from 250ms to be more tolerant + private val TIMEOUT = 5.seconds } private var queueJob: Job? = null @@ -71,15 +75,11 @@ constructor( private val queuedPackets = ConcurrentLinkedQueue() private val queueResponse = ConcurrentHashMap>() - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope } - /** - * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully - * bound to the RadioInterfaceService - */ - fun sendToRadio(p: ToRadio) { + override fun sendToRadio(p: ToRadio) { Logger.d { "Sending to radio ${p.toPIIString()}" } val b = p.encode() @@ -94,7 +94,7 @@ constructor( message_type = "Packet", received_date = nowMillis, raw_message = packet.toString(), - fromNum = MeshLog.NODE_NUM_LOCAL, // Outgoing packets are always from the local node + fromNum = MeshLog.NODE_NUM_LOCAL, portNum = packet.decoded?.portnum?.value ?: 0, fromRadio = FromRadio(packet = packet), ) @@ -102,16 +102,12 @@ constructor( } } - /** - * Send a mesh packet to the radio, if the radio is not currently connected this function will throw - * NotConnectedException - */ - fun sendToRadio(packet: MeshPacket) { + override fun sendToRadio(packet: MeshPacket) { queuedPackets.add(packet) startPacketQueue() } - fun stopPacketQueue() { + override fun stopPacketQueue() { if (queueJob?.isActive == true) { Logger.i { "Stopping packet queueJob" } queueJob?.cancel() @@ -122,33 +118,30 @@ constructor( } } - fun handleQueueStatus(queueStatus: QueueStatus) { + override fun handleQueueStatus(queueStatus: QueueStatus) { Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" } val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) } - if (success && isFull) return // Queue is full, wait for free != 0 + if (success && isFull) return if (requestId != 0) { queueResponse.remove(requestId)?.complete(success) } else { - // This is slightly suboptimal but matches legacy behavior for packets without IDs queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success) } } - fun removeResponse(dataRequestId: Int, complete: Boolean) { + override fun removeResponse(dataRequestId: Int, complete: Boolean) { queueResponse.remove(dataRequestId)?.complete(complete) } - @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun startPacketQueue() { if (queueJob?.isActive == true) return queueJob = scope.handledLaunch { Logger.d { "packet queueJob started" } - while (connectionStateHolder.connectionState.value == ConnectionState.Connected) { - // take the first packet from the queue head + while (serviceRepository.connectionState.value == ConnectionState.Connected) { val packet = queuedPackets.poll() ?: break + @Suppress("TooGenericExceptionCaught", "SwallowedException") try { - // send packet to the radio and wait for response val response = sendPacket(packet) Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" } val success = withTimeout(TIMEOUT) { response.await() } @@ -164,7 +157,6 @@ constructor( } } - /** Change the status on a DataPacket and update watchers */ private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch { if (packetId != 0) { getDataPacketById(packetId)?.let { p -> @@ -175,11 +167,10 @@ constructor( } } - @Suppress("MagicNumber") private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) { var dataPacket: DataPacket? = null while (dataPacket == null) { - dataPacket = packetRepository.get().getPacketById(packetId)?.data + dataPacket = packetRepository.get().getPacketById(packetId) if (dataPacket == null) delay(100.milliseconds) } dataPacket @@ -187,17 +178,14 @@ constructor( @Suppress("TooGenericExceptionCaught") private fun sendPacket(packet: MeshPacket): CompletableDeferred { - // send the packet to the radio and return a CompletableDeferred that will be completed with - // the result val deferred = CompletableDeferred() queueResponse[packet.id] = deferred try { - if (connectionStateHolder.connectionState.value != ConnectionState.Connected) { + if (serviceRepository.connectionState.value != ConnectionState.Connected) { throw RadioNotConnectedException() } sendToRadio(ToRadio(packet = packet)) } catch (ex: RadioNotConnectedException) { - // Expected when radio is not connected, log as warning to avoid Crashlytics noise Logger.w(ex) { "sendToRadio skipped: Not connected to radio" } deferred.complete(false) } catch (ex: Exception) { @@ -209,8 +197,6 @@ constructor( private fun insertMeshLog(packetToSave: MeshLog) { scope.handledLaunch { - // Do not log, because might contain PII - Logger.d { "insert: ${packetToSave.message_type} = " + "${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}" diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt similarity index 74% rename from app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index 0ca3e3947c..2524e83018 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope @@ -22,48 +22,47 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.TracerouteSnapshotRepository +import org.meshtastic.core.model.Node import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getFullTracerouteResponse -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.traceroute_duration -import org.meshtastic.core.resources.traceroute_route_back_to_us -import org.meshtastic.core.resources.traceroute_route_towards_dest -import org.meshtastic.core.resources.unknown_username -import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.core.service.TracerouteResponse +import org.meshtastic.core.model.service.TracerouteResponse +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.proto.MeshPacket import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @Singleton -class MeshTracerouteHandler +class TracerouteHandlerImpl @Inject constructor( - private val nodeManager: MeshNodeManager, + private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val nodeRepository: NodeRepository, - private val commandSender: MeshCommandSender, -) { + private val commandSender: CommandSender, +) : TracerouteHandler { private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - fun start(scope: CoroutineScope) { + override fun start(scope: CoroutineScope) { this.scope = scope } - fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) { + override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) { val full = packet.getFullTracerouteResponse( getUser = { num -> - nodeManager.nodeDBbyNodeNum[num]?.let { "${it.longName} (${it.shortName})" } - ?: getString(Res.string.unknown_username) + nodeManager.nodeDBbyNodeNum[num]?.let { node: Node -> + "${node.user.long_name} (${node.user.short_name})" + } ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later }, - headerTowards = getString(Res.string.traceroute_route_towards_dest), - headerBack = getString(Res.string.traceroute_route_back_to_us), + headerTowards = "Route towards destination:", + headerBack = "Route back to us:", ) ?: return val requestId = packet.decoded?.request_id ?: 0 @@ -87,7 +86,7 @@ constructor( val elapsedMs = nowMillis - start val seconds = elapsedMs / MILLIS_PER_SECOND Logger.i { "Traceroute $requestId complete in $seconds s" } - val durationText = getString(Res.string.traceroute_duration, "%.1f".format(Locale.US, seconds)) + val durationText = "Duration: %.1f s".format(Locale.US, seconds) "$full\n\n$durationText" } else { full diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt similarity index 97% rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index d189f19f77..d4901d02b1 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -29,12 +29,13 @@ import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.network.DeviceHardwareRemoteDataSource +import org.meshtastic.core.repository.DeviceHardwareRepository import javax.inject.Inject import javax.inject.Singleton // Annotating with Singleton to ensure a single instance manages the cache @Singleton -class DeviceHardwareRepository +class DeviceHardwareRepositoryImpl @Inject constructor( private val remoteDataSource: DeviceHardwareRemoteDataSource, @@ -42,7 +43,7 @@ constructor( private val jsonDataSource: DeviceHardwareJsonDataSource, private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource, private val dispatchers: CoroutineDispatchers, -) { +) : DeviceHardwareRepository { /** * Retrieves device hardware information by its model ID and optional target string. @@ -59,10 +60,10 @@ constructor( * @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure. */ @Suppress("LongMethod", "detekt:CyclomaticComplexMethod") - suspend fun getDeviceHardwareByModel( + override suspend fun getDeviceHardwareByModel( hwModel: Int, - target: String? = null, - forceRefresh: Boolean = false, + target: String?, + forceRefresh: Boolean, ): Result = withContext(dispatchers.io) { Logger.d { "DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," + diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt similarity index 67% rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 53729ce489..a6af8c51e8 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -40,13 +40,16 @@ import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.NodeSortOption import org.meshtastic.core.datastore.LocalStatsDataSource import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.di.ProcessLifecycle import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.util.onlineTimeThreshold +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.LocalStats import org.meshtastic.proto.User @@ -56,7 +59,7 @@ import javax.inject.Singleton /** Repository for managing node-related data, including hardware info, node database, and identity. */ @Singleton @Suppress("TooManyFunctions") -open class NodeRepository +class NodeRepositoryImpl @Inject constructor( @ProcessLifecycle private val processLifecycle: Lifecycle, @@ -64,28 +67,29 @@ constructor( private val nodeInfoWriteDataSource: NodeInfoWriteDataSource, private val dispatchers: CoroutineDispatchers, private val localStatsDataSource: LocalStatsDataSource, -) { +) : NodeRepository { /** Hardware info about our local device (can be null if not connected). */ - open val myNodeInfo: StateFlow = + override val myNodeInfo: StateFlow = nodeInfoReadDataSource .myNodeInfoFlow() + .map { it?.toMyNodeInfo() } .flowOn(dispatchers.io) .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) private val _ourNodeInfo = MutableStateFlow(null) /** Information about the locally connected node, as seen from the mesh. */ - open val ourNodeInfo: StateFlow + override val ourNodeInfo: StateFlow get() = _ourNodeInfo private val _myId = MutableStateFlow(null) /** The unique userId (hex string) of our local node. */ - val myId: StateFlow + override val myId: StateFlow get() = _myId /** The latest local stats telemetry received from the locally connected node. */ - val localStats: StateFlow = + override val localStats: StateFlow = localStatsDataSource.localStatsFlow.stateIn( processLifecycle.coroutineScope, SharingStarted.Eagerly, @@ -93,12 +97,12 @@ constructor( ) /** Update the cached local stats telemetry. */ - fun updateLocalStats(stats: LocalStats) { + override fun updateLocalStats(stats: LocalStats) { processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) } } /** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */ - val nodeDBbyNum: StateFlow> = + override val nodeDBbyNum: StateFlow> = nodeInfoReadDataSource .nodeDBbyNumFlow() .mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } } @@ -115,7 +119,7 @@ constructor( } // Keep ourNodeInfo and myId correctly updated based on current connection and node DB - combine(nodeDBbyNum, myNodeInfo) { db, info -> info?.myNodeNum?.let { db[it] } } + combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } } .onEach { node -> _ourNodeInfo.value = node _myId.value = node?.user?.id @@ -127,7 +131,8 @@ constructor( * Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally * connected node. */ - fun effectiveLogNodeId(nodeNum: Int): Flow = myNodeInfo + override fun effectiveLogNodeId(nodeNum: Int): Flow = nodeInfoReadDataSource + .myNodeInfoFlow() .map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum } .distinctUntilChanged() @@ -135,14 +140,14 @@ constructor( nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } } /** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */ - fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } + override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } ?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) /** Returns the [User] info for a given [nodeNum]. */ - fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) + override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) /** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */ - fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user + override fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user ?: User( id = userId, long_name = @@ -161,13 +166,13 @@ constructor( ) /** Returns a flow of nodes filtered and sorted according to the parameters. */ - fun getNodes( - sort: NodeSortOption = NodeSortOption.LAST_HEARD, - filter: String = "", - includeUnknown: Boolean = true, - onlyOnline: Boolean = false, - onlyDirect: Boolean = false, - ) = nodeInfoReadDataSource + override fun getNodes( + sort: NodeSortOption, + filter: String, + includeUnknown: Boolean, + onlyOnline: Boolean, + onlyDirect: Boolean, + ): Flow> = nodeInfoReadDataSource .getNodesFlow( sort = sort.sqlValue, filter = filter, @@ -179,44 +184,46 @@ constructor( .flowOn(dispatchers.io) .conflate() - /** Upserts a [NodeEntity] to the database. */ - suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node) } + /** Upserts a [Node] to the database. */ + override suspend fun upsert(node: Node) = + withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) } /** Installs initial configuration data (local info and remote nodes) into the database. */ - suspend fun installConfig(mi: MyNodeEntity, nodes: List) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.installConfig(mi, nodes) } + override suspend fun installConfig(mi: MyNodeInfo, nodes: List) = withContext(dispatchers.io) { + nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() }) + } /** Deletes all nodes from the database, optionally preserving favorites. */ - suspend fun clearNodeDB(preserveFavorites: Boolean = false) = + override suspend fun clearNodeDB(preserveFavorites: Boolean) = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) } /** Clears the local node's connection info. */ - suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() } + override suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() } /** Deletes a node and its metadata by [num]. */ - suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { + override suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { nodeInfoWriteDataSource.deleteNode(num) nodeInfoWriteDataSource.deleteMetadata(num) } /** Deletes multiple nodes and their metadata. */ - suspend fun deleteNodes(nodeNums: List) = withContext(dispatchers.io) { + override suspend fun deleteNodes(nodeNums: List) = withContext(dispatchers.io) { nodeInfoWriteDataSource.deleteNodes(nodeNums) nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) } } - suspend fun getNodesOlderThan(lastHeard: Int): List = - withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard) } + override suspend fun getNodesOlderThan(lastHeard: Int): List = + withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard).map { it.toModel() } } - suspend fun getUnknownNodes(): List = - withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes() } + override suspend fun getUnknownNodes(): List = + withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } } /** Persists hardware metadata for a node. */ - suspend fun insertMetadata(metadata: MetadataEntity) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(metadata) } + override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = + withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) } /** Flow emitting the count of nodes currently considered "online". */ - val onlineNodeCount: Flow = + override val onlineNodeCount: Flow = nodeInfoReadDataSource .nodeDBbyNumFlow() .mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } } @@ -224,14 +231,52 @@ constructor( .conflate() /** Flow emitting the total number of nodes in the database. */ - val totalNodeCount: Flow = + override val totalNodeCount: Flow = nodeInfoReadDataSource .nodeDBbyNumFlow() .mapLatest { map -> map.values.count() } .flowOn(dispatchers.io) .conflate() - /** Updates the personal notes field for a node. */ - suspend fun setNodeNotes(num: Int, notes: String) = + override suspend fun setNodeNotes(num: Int, notes: String) = withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) } + + private fun MyNodeInfo.toEntity() = MyNodeEntity( + myNodeNum = myNodeNum, + model = model, + firmwareVersion = firmwareVersion, + couldUpdate = couldUpdate, + shouldUpdate = shouldUpdate, + currentPacketId = currentPacketId, + messageTimeoutMsec = messageTimeoutMsec, + minAppVersion = minAppVersion, + maxChannels = maxChannels, + hasWifi = hasWifi, + deviceId = deviceId, + pioEnv = pioEnv, + ) + + private fun Node.toEntity() = NodeEntity( + num = num, + user = user, + position = position, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceTelemetry = org.meshtastic.proto.Telemetry(device_metrics = deviceMetrics), + channel = channel, + viaMqtt = viaMqtt, + hopsAway = hopsAway, + isFavorite = isFavorite, + isIgnored = isIgnored, + isMuted = isMuted, + environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics), + powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics), + paxcounter = paxcounter, + publicKey = publicKey, + notes = notes, + manuallyVerified = manuallyVerified, + nodeStatus = nodeStatus, + lastTransport = lastTransport, + ) } 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 deleted file mode 100644 index d65898086c..0000000000 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ /dev/null @@ -1,361 +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 . - */ -package org.meshtastic.core.data.repository - -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.map -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.withContext -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.database.DatabaseManager -import org.meshtastic.core.database.entity.ContactSettings -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.entity.ReactionEntity -import org.meshtastic.core.database.model.Message -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.proto.ChannelSettings -import org.meshtastic.proto.PortNum -import javax.inject.Inject - -class PacketRepository -@Inject -constructor( - private val dbManager: DatabaseManager, - private val dispatchers: CoroutineDispatchers, -) { - fun getWaypoints(): Flow> = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() } - - fun getContacts(): Flow> = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() } - - fun getContactsPaged(): Flow> = Pager( - config = - PagingConfig( - pageSize = CONTACTS_PAGE_SIZE, - enablePlaceholders = false, - initialLoadSize = CONTACTS_PAGE_SIZE, - ), - pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() }, - ) - .flow - - suspend fun getMessageCount(contact: String): Int = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) } - - suspend fun getUnreadCount(contact: String): Int = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) } - - fun getFirstUnreadMessageUuid(contact: String): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) } - - fun hasUnreadMessages(contact: String): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) } - - fun getUnreadCountTotal(): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } - - suspend fun clearUnreadCount(contact: String, timestamp: Long) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } - - suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - withContext(dispatchers.io) { - val dao = dbManager.currentDb.value.packetDao() - val current = dao.getContactSettings(contact) - val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE - if (lastReadTimestamp <= existingTimestamp) { - return@withContext - } - val updated = - (current ?: ContactSettings(contact_key = contact)).copy( - lastReadMessageUuid = messageUuid, - lastReadMessageTimestamp = lastReadTimestamp, - ) - dao.upsertContactSettings(listOf(updated)) - } - - suspend fun getQueuedPackets(): List? = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } - - suspend fun insert(packet: Packet) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) } - - 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 = - PagingConfig( - pageSize = MESSAGES_PAGE_SIZE, - enablePlaceholders = false, - initialLoadSize = MESSAGES_PAGE_SIZE, - ), - pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) }, - ) - .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 updateMessageStatus(d: DataPacket, m: MessageStatus) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) } - - suspend fun updateMessageId(d: DataPacket, id: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) } - - suspend fun getPacketById(requestId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(requestId) } - - suspend fun getPacketByPacketId(packetId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) } - - suspend fun findPacketsWithId(packetId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findPacketsWithId(packetId) } - - @Suppress("CyclomaticComplexMethod") - suspend fun updateSFPPStatus( - packetId: Int, - from: Int, - to: Int, - hash: ByteArray, - status: MessageStatus = MessageStatus.SFPP_CONFIRMED, - rxTime: Long = 0, - myNodeNum: Int? = null, - ) = withContext(dispatchers.io) { - val dao = dbManager.currentDb.value.packetDao() - val packets = dao.findPacketsWithId(packetId) - val reactions = dao.findReactionsWithId(packetId) - val fromId = DataPacket.nodeNumToDefaultId(from) - val isFromLocalNode = myNodeNum != null && from == myNodeNum - val toId = - if (to == 0 || to == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - DataPacket.nodeNumToDefaultId(to) - } - - val hashByteString = hash.toByteString() - - packets.forEach { packet -> - // For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number - val fromMatches = - packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL) - co.touchlab.kermit.Logger.d { - "SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " + - "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + - "packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}" - } - if (fromMatches && packet.data.to == toId) { - // If it's already confirmed, don't downgrade it to routing - if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@forEach - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time - val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) - dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) - } - } - - reactions.forEach { reaction -> - val reactionFrom = reaction.userId - // For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number - val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL) - - val toMatches = reaction.to == toId - - co.touchlab.kermit.Logger.d { - "SFPP reaction match check: reactionFrom=$reactionFrom fromId=$fromId " + - "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + - "reactionTo=${reaction.to} toId=$toId toMatches=$toMatches" - } - - if (fromMatches && (reaction.to == null || toMatches)) { - if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@forEach - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp - val updatedReaction = - reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) - dao.update(updatedReaction) - } - } - } - - suspend fun updateSFPPStatusByHash( - hash: ByteArray, - status: MessageStatus = MessageStatus.SFPP_CONFIRMED, - rxTime: Long = 0, - ) = withContext(dispatchers.io) { - val dao = dbManager.currentDb.value.packetDao() - val hashByteString = hash.toByteString() - dao.findPacketBySfppHash(hashByteString)?.let { packet -> - // If it's already confirmed, don't downgrade it - if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@let - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time - val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) - dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) - } - - dao.findReactionBySfppHash(hashByteString)?.let { reaction -> - if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@let - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp - val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) - dao.update(updatedReaction) - } - } - - suspend fun deleteMessages(uuidList: List) = withContext(dispatchers.io) { - for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) { - // Fetch DAO per chunk to avoid holding a stale reference if the active DB switches - dbManager.currentDb.value.packetDao().deleteMessages(chunk) - } - } - - suspend fun deleteContacts(contactList: List) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(contactList) } - - suspend fun deleteWaypoint(id: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(id) } - - suspend fun delete(packet: Packet) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().delete(packet) } - - suspend fun update(packet: Packet) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(packet) } - - fun getContactSettings(): Flow> = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactSettings() } - - suspend fun getContactSettings(contact: String) = withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().getContactSettings(contact) ?: ContactSettings(contact) - } - - suspend fun setMuteUntil(contacts: List, until: Long) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setMuteUntil(contacts, until) } - - suspend fun insertReaction(reaction: ReactionEntity) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) } - - suspend fun updateReaction(reaction: ReactionEntity) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) } - - suspend fun getReactionByPacketId(packetId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId) } - - 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) = - withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings) - } - - suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) { - val pattern = "%\"from\":\"${senderId}\"%" - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateFilteredBySender(pattern, filtered) } - } - - private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow> = - getAllPackets(PortNum.WAYPOINT_APP.value) - - companion object { - private const val CONTACTS_PAGE_SIZE = 30 - private const val MESSAGES_PAGE_SIZE = 50 - private const val DELETE_CHUNK_SIZE = 500 - private const val MILLISECONDS_IN_SECOND = 1000L - } -} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt new file mode 100644 index 0000000000..e29c82be1b --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -0,0 +1,482 @@ +/* + * 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.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.database.DatabaseManager +import org.meshtastic.core.database.entity.toReaction +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.ContactSettings +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Message +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.PortNum +import javax.inject.Inject +import org.meshtastic.core.database.entity.ContactSettings as ContactSettingsEntity +import org.meshtastic.core.database.entity.Packet as RoomPacket +import org.meshtastic.core.database.entity.ReactionEntity as RoomReaction +import org.meshtastic.core.repository.PacketRepository as SharedPacketRepository + +@Suppress("TooManyFunctions", "LongParameterList") +class PacketRepositoryImpl +@Inject +constructor( + private val dbManager: DatabaseManager, + private val dispatchers: CoroutineDispatchers, +) : SharedPacketRepository { + + override fun getWaypoints(): Flow> = dbManager.currentDb + .flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() } + .map { list -> list.map { it.data } } + + override fun getContacts(): Flow> = dbManager.currentDb + .flatMapLatest { db -> db.packetDao().getContactKeys() } + .map { map -> map.mapValues { it.value.data } } + + override fun getContactsPaged(): Flow> = Pager( + config = + PagingConfig( + pageSize = CONTACTS_PAGE_SIZE, + enablePlaceholders = false, + initialLoadSize = CONTACTS_PAGE_SIZE, + ), + pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() }, + ) + .flow + .map { pagingData -> pagingData.map { it.data } } + + override suspend fun getMessageCount(contact: String): Int = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) } + + override suspend fun getUnreadCount(contact: String): Int = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) } + + override fun getFirstUnreadMessageUuid(contact: String): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) } + + override fun hasUnreadMessages(contact: String): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) } + + override fun getUnreadCountTotal(): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } + + override suspend fun clearUnreadCount(contact: String, timestamp: Long) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } + + override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = + withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + val current = dao.getContactSettings(contact) + val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE + if (lastReadTimestamp <= existingTimestamp) { + return@withContext + } + val updated = + (current ?: ContactSettingsEntity(contact_key = contact)).copy( + lastReadMessageUuid = messageUuid, + lastReadMessageTimestamp = lastReadTimestamp, + ) + dao.upsertContactSettings(listOf(updated)) + } + + override suspend fun getQueuedPackets(): List? = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } + + suspend fun insertRoomPacket(packet: RoomPacket) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) } + + override suspend fun savePacket( + myNodeNum: Int, + contactKey: String, + packet: DataPacket, + receivedTime: Long, + read: Boolean, + filtered: Boolean, + ) { + val packetToSave = + RoomPacket( + uuid = 0L, + myNodeNum = myNodeNum, + packetId = packet.id, + port_num = packet.dataType, + contact_key = contactKey, + received_time = receivedTime, + read = read, + data = packet, + snr = packet.snr, + rssi = packet.rssi, + hopsAway = packet.hopsAway, + filtered = filtered, + ) + insertRoomPacket(packetToSave) + } + + override suspend fun getMessagesFrom( + contact: String, + limit: Int?, + includeFiltered: Boolean, + getNode: suspend (String?) -> Node, + ): Flow> = 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 { getPacketByPacketIdInternal(it) } + ?.let { originalPacket -> originalPacket.toMessage(getNode) } + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + } + } + } + + override fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow> = + Pager( + config = + PagingConfig( + pageSize = MESSAGES_PAGE_SIZE, + enablePlaceholders = false, + initialLoadSize = MESSAGES_PAGE_SIZE, + ), + pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) }, + ) + .flow + .map { pagingData -> + pagingData.map { packet -> + val message = packet.toMessage(getNode) + message.replyId + .takeIf { it != null && it != 0 } + ?.let { getPacketByPacketIdInternal(it) } + ?.let { originalPacket -> originalPacket.toMessage(getNode) } + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + } + } + + override 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 { getPacketByPacketIdInternal(it) } + ?.let { originalPacket -> originalPacket.toMessage(getNode) } + ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message + } + } + + override suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) } + + override suspend fun updateMessageId(d: DataPacket, id: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) } + + override suspend fun getPacketById(id: Int): DataPacket? = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(id)?.data } + + override suspend fun getPacketByPacketId(packetId: Int): DataPacket? = withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId)?.packet?.data + } + + private suspend fun getPacketByPacketIdInternal(packetId: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) } + + override suspend fun insert( + packet: DataPacket, + myNodeNum: Int, + contactKey: String, + receivedTime: Long, + read: Boolean, + filtered: Boolean, + ) { + val packetToSave = + RoomPacket( + uuid = 0L, + myNodeNum = myNodeNum, + packetId = packet.id, + port_num = packet.dataType, + contact_key = contactKey, + received_time = receivedTime, + read = read, + data = packet, + snr = packet.snr, + rssi = packet.rssi, + hopsAway = packet.hopsAway, + filtered = filtered, + ) + insertRoomPacket(packetToSave) + } + + override suspend fun update(packet: DataPacket): Unit = withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + // Match on key fields that identify the packet, rather than the entire data object + dao.findPacketsWithId(packet.id) + .find { it.data.id == packet.id && it.data.from == packet.from && it.data.to == packet.to } + ?.let { dao.update(it.copy(data = packet)) } + } + + override suspend fun insertReaction(reaction: Reaction, myNodeNum: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction.toEntity(myNodeNum)) } + + override suspend fun updateReaction(reaction: Reaction) = withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + dao.findReactionsWithId(reaction.packetId) + .find { it.userId == reaction.user.id && it.emoji == reaction.emoji } + ?.let { dao.update(reaction.toEntity(it.myNodeNum)) } ?: Unit + } + + override suspend fun getReactionByPacketId(packetId: Int): Reaction? = withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId)?.toReaction { null } + } + + override suspend fun findPacketsWithId(packetId: Int): List = withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().findPacketsWithId(packetId).map { it.data } + } + + private suspend fun findPacketsWithIdInternal(packetId: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findPacketsWithId(packetId) } + + override suspend fun findReactionsWithId(packetId: Int): List = withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().findReactionsWithId(packetId).toReaction { null } + } + + private suspend fun findReactionsWithIdInternal(packetId: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(packetId) } + + @Suppress("CyclomaticComplexMethod") + override suspend fun updateSFPPStatus( + packetId: Int, + from: Int, + to: Int, + hash: ByteArray, + status: MessageStatus, + rxTime: Long, + myNodeNum: Int?, + ) = withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + val packets = findPacketsWithIdInternal(packetId) + val reactions = findReactionsWithIdInternal(packetId) + val fromId = DataPacket.nodeNumToDefaultId(from) + val isFromLocalNode = myNodeNum != null && from == myNodeNum + val toId = + if (to == 0 || to == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + DataPacket.nodeNumToDefaultId(to) + } + + val hashByteString = hash.toByteString() + + packets.forEach { packet -> + // For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number + val fromMatches = + packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL) + co.touchlab.kermit.Logger.d { + "SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " + + "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + + "packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}" + } + if (fromMatches && packet.data.to == toId) { + // If it's already confirmed, don't downgrade it to routing + if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@forEach + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time + val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) + dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) + } + } + + reactions.forEach { reaction -> + val reactionFrom = reaction.userId + // For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number + val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL) + + val toMatches = reaction.to == toId + + co.touchlab.kermit.Logger.d { + "SFPP reaction match check: reactionFrom=$reactionFrom fromId=$fromId " + + "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + + "reactionTo=${reaction.to} toId=$toId toMatches=$toMatches" + } + + if (fromMatches && (reaction.to == null || toMatches)) { + if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@forEach + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp + val updatedReaction = + reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) + dao.update(updatedReaction) + } + } + } + + override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long): Unit = + withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + val hashByteString = hash.toByteString() + dao.findPacketBySfppHash(hashByteString)?.let { packet -> + // If it's already confirmed, don't downgrade it + if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@let + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time + val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) + dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) + } + + dao.findReactionBySfppHash(hashByteString)?.let { reaction -> + if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@let + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp + val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) + dao.update(updatedReaction) + } + } + + override suspend fun deleteMessages(uuidList: List) = withContext(dispatchers.io) { + for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) { + // Fetch DAO per chunk to avoid holding a stale reference if the active DB switches + dbManager.currentDb.value.packetDao().deleteMessages(chunk) + } + } + + override suspend fun deleteContacts(contactList: List) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(contactList) } + + override suspend fun deleteWaypoint(id: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(id) } + + suspend fun delete(packet: RoomPacket) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().delete(packet) } + + suspend fun update(packet: RoomPacket) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(packet) } + + override fun getContactSettings(): Flow> = dbManager.currentDb + .flatMapLatest { db -> db.packetDao().getContactSettings() } + .map { map -> map.mapValues { it.value.toShared() } } + + override suspend fun getContactSettings(contact: String): ContactSettings = withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().getContactSettings(contact)?.toShared() ?: ContactSettings(contact) + } + + override suspend fun setMuteUntil(contacts: List, until: Long) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setMuteUntil(contacts, until) } + + suspend fun insertReaction(reaction: RoomReaction) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) } + + suspend fun updateReaction(reaction: RoomReaction) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) } + + override fun getFilteredCountFlow(contactKey: String): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(contactKey) } + + override suspend fun getFilteredCount(contactKey: String): Int = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(contactKey) } + + override suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) = + withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().setContactFilteringDisabled(contactKey, disabled) + } + + override suspend fun clearPacketDB() = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() } + + override suspend fun migrateChannelsByPSK(oldSettings: List, newSettings: List) = + withContext(dispatchers.io) { + dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings) + } + + override suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) { + val pattern = "%\"from\":\"${senderId}\"%" + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateFilteredBySender(pattern, filtered) } + } + + private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow> = + getAllPackets(PortNum.WAYPOINT_APP.value) + + private fun ContactSettingsEntity.toShared() = ContactSettings( + contactKey = contact_key, + muteUntil = muteUntil, + lastReadMessageUuid = lastReadMessageUuid, + lastReadMessageTimestamp = lastReadMessageTimestamp, + filteringDisabled = filteringDisabled, + isMuted = isMuted, + ) + + private fun Reaction.toEntity(myNodeNum: Int) = RoomReaction( + myNodeNum = myNodeNum, + replyId = replyId, + userId = user.id, + emoji = emoji, + timestamp = timestamp, + snr = snr, + rssi = rssi, + hopsAway = hopsAway, + packetId = packetId, + status = status, + routingError = routingError, + relays = relays, + relayNode = relayNode, + to = to, + channel = channel, + sfpp_hash = sfppHash, + ) + + companion object { + private const val CONTACTS_PAGE_SIZE = 30 + private const val MESSAGES_PAGE_SIZE = 50 + private const val DELETE_CHUNK_SIZE = 500 + private const val MILLISECONDS_IN_SECOND = 1000L + } +} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt similarity index 80% rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt index 1e4067f80e..d76ac8eeec 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt @@ -22,6 +22,8 @@ import org.meshtastic.core.datastore.ChannelSetDataSource import org.meshtastic.core.datastore.LocalConfigDataSource import org.meshtastic.core.datastore.ModuleConfigDataSource import org.meshtastic.core.model.util.getChannelUrl +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings @@ -36,25 +38,25 @@ import javax.inject.Inject * Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] & * [LocalModuleConfig]. */ -open class RadioConfigRepository +open class RadioConfigRepositoryImpl @Inject constructor( private val nodeDB: NodeRepository, private val channelSetDataSource: ChannelSetDataSource, private val localConfigDataSource: LocalConfigDataSource, private val moduleConfigDataSource: ModuleConfigDataSource, -) { +) : RadioConfigRepository { /** Flow representing the [ChannelSet] data store. */ - val channelSetFlow: Flow = channelSetDataSource.channelSetFlow + override val channelSetFlow: Flow = channelSetDataSource.channelSetFlow /** Clears the [ChannelSet] data in the data store. */ - suspend fun clearChannelSet() { + override suspend fun clearChannelSet() { channelSetDataSource.clearChannelSet() } /** Replaces the [ChannelSettings] list with a new [settingsList]. */ - suspend fun replaceAllSettings(settingsList: List) { + override suspend fun replaceAllSettings(settingsList: List) { channelSetDataSource.replaceAllSettings(settingsList) } @@ -65,13 +67,13 @@ constructor( * @param channel The [Channel] provided. * @return the index of the admin channel after the update (if not found, returns 0). */ - suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel) + override suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel) /** Flow representing the [LocalConfig] data store. */ - open val localConfigFlow: Flow = localConfigDataSource.localConfigFlow + override val localConfigFlow: Flow = localConfigDataSource.localConfigFlow /** Clears the [LocalConfig] data in the data store. */ - suspend fun clearLocalConfig() { + override suspend fun clearLocalConfig() { localConfigDataSource.clearLocalConfig() } @@ -80,16 +82,16 @@ constructor( * * @param config The [Config] to be set. */ - suspend fun setLocalConfig(config: Config) { + override suspend fun setLocalConfig(config: Config) { localConfigDataSource.setLocalConfig(config) config.lora?.let { channelSetDataSource.setLoraConfig(it) } } /** Flow representing the [LocalModuleConfig] data store. */ - val moduleConfigFlow: Flow = moduleConfigDataSource.moduleConfigFlow + override val moduleConfigFlow: Flow = moduleConfigDataSource.moduleConfigFlow /** Clears the [LocalModuleConfig] data in the data store. */ - suspend fun clearLocalModuleConfig() { + override suspend fun clearLocalModuleConfig() { moduleConfigDataSource.clearLocalModuleConfig() } @@ -98,12 +100,12 @@ constructor( * * @param config The [ModuleConfig] to be set. */ - suspend fun setLocalModuleConfig(config: ModuleConfig) { + override suspend fun setLocalModuleConfig(config: ModuleConfig) { moduleConfigDataSource.setLocalModuleConfig(config) } /** Flow representing the combined [DeviceProfile] protobuf. */ - val deviceProfileFlow: Flow = + override val deviceProfileFlow: Flow = combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) { node, channels, diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderHopLimitTest.kt similarity index 78% rename from app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderHopLimitTest.kt index c7f2e2e878..679729176b 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderHopLimitTest.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import io.mockk.every import io.mockk.mockk @@ -29,35 +29,39 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.User -class MeshCommandSenderHopLimitTest { +class CommandSenderHopLimitTest { private val packetHandler: PacketHandler = mockk(relaxed = true) - private val nodeManager = MeshNodeManager() - private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true) + private val nodeManager: NodeManager = mockk(relaxed = true) private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true) private val localConfigFlow = MutableStateFlow(LocalConfig()) private val testDispatcher = UnconfinedTestDispatcher() private val testScope = CoroutineScope(testDispatcher) - private lateinit var commandSender: MeshCommandSender + private lateinit var commandSender: CommandSender @Before fun setUp() { - val connectedFlow = MutableStateFlow(ConnectionState.Connected) - every { connectionStateHolder.connectionState } returns connectedFlow + val myNum = 123 + val myNode = Node(num = myNum, user = User(id = "!id", long_name = "long", short_name = "shrt")) every { radioConfigRepository.localConfigFlow } returns localConfigFlow + every { nodeManager.myNodeNum } returns myNum + every { nodeManager.nodeDBbyNodeNum } returns mapOf(myNum to myNode) - commandSender = MeshCommandSender(packetHandler, nodeManager, connectionStateHolder, radioConfigRepository) + commandSender = CommandSenderImpl(packetHandler, nodeManager, radioConfigRepository) commandSender.start(testScope) - nodeManager.myNodeNum = 123 } @Test @@ -111,7 +115,10 @@ class MeshCommandSenderHopLimitTest { localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 6)) // Mock node manager interactions - nodeManager.nodeDBbyNodeNum.remove(destNum) + // Note: we need to keep myNode in the map for requestUserInfo to not return early + val myNum = 123 + val myNode = Node(num = myNum, user = User(id = "!id", long_name = "long", short_name = "shrt")) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(myNum to myNode) commandSender.requestUserInfo(destNum) diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt similarity index 76% rename from app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt index 22ffe3a603..69996dde9b 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt @@ -14,25 +14,28 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager +import io.mockk.every +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Before import org.junit.Test -import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeManager import org.meshtastic.proto.User -class MeshCommandSenderTest { +class CommandSenderImplTest { - private lateinit var commandSender: MeshCommandSender - private lateinit var nodeManager: MeshNodeManager + private lateinit var commandSender: CommandSenderImpl + private lateinit var nodeManager: NodeManager @Before fun setUp() { - nodeManager = MeshNodeManager() - commandSender = MeshCommandSender(null, nodeManager, null, null) + nodeManager = mockk(relaxed = true) + commandSender = CommandSenderImpl(mockk(relaxed = true), nodeManager, mockk(relaxed = true)) } @Test @@ -60,9 +63,8 @@ class MeshCommandSenderTest { fun `resolveNodeNum handles custom node ID from database`() { val nodeNum = 456 val userId = "custom_id" - val entity = NodeEntity(num = nodeNum, user = User(id = userId)) - nodeManager.nodeDBbyNodeNum[nodeNum] = entity - nodeManager.nodeDBbyID[userId] = entity + val node = Node(num = nodeNum, user = User(id = userId)) + every { nodeManager.nodeDBbyID } returns mapOf(userId to node) assertEquals(nodeNum, commandSender.resolveNodeNum(userId)) } diff --git a/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt similarity index 82% rename from app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index 82b26c6e6e..e1b0c414f2 100644 --- a/app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -14,14 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager +import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.Before import org.junit.Test -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.MeshRouter +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata @@ -30,18 +34,19 @@ import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.QueueStatus -class FromRadioPacketHandlerTest { +class FromRadioPacketHandlerImplTest { private val serviceRepository: ServiceRepository = mockk(relaxed = true) private val router: MeshRouter = mockk(relaxed = true) - private val mqttManager: MeshMqttManager = mockk(relaxed = true) + private val mqttManager: MqttManager = mockk(relaxed = true) private val packetHandler: PacketHandler = mockk(relaxed = true) private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true) - private lateinit var handler: FromRadioPacketHandler + private lateinit var handler: FromRadioPacketHandlerImpl @Before fun setup() { - handler = FromRadioPacketHandler(serviceRepository, router, mqttManager, packetHandler, serviceNotifications) + handler = + FromRadioPacketHandlerImpl(serviceRepository, { router }, mqttManager, packetHandler, serviceNotifications) } @Test @@ -69,10 +74,12 @@ class FromRadioPacketHandlerTest { val nodeInfo = NodeInfo(num = 1234) val proto = FromRadio(node_info = nodeInfo) + every { router.configFlowManager.newNodeCount } returns 1 + handler.handleFromRadio(proto) verify { router.configFlowManager.handleNodeInfo(nodeInfo) } - verify { serviceRepository.setConnectionProgress(any()) } + verify { serviceRepository.setConnectionProgress("Nodes (1)") } } @Test diff --git a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt similarity index 86% rename from app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt index 88d318b266..ebf0ca0654 100644 --- a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt @@ -14,18 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import org.junit.Assert.assertEquals import org.junit.Test import org.meshtastic.proto.StoreAndForward -class StoreForwardHistoryRequestTest { +class HistoryManagerImplTest { @Test fun `buildStoreForwardHistoryRequest copies positive parameters`() { val request = - MeshHistoryManager.buildStoreForwardHistoryRequest( + HistoryManagerImpl.buildStoreForwardHistoryRequest( lastRequest = 42, historyReturnWindow = 15, historyReturnMax = 25, @@ -40,7 +40,7 @@ class StoreForwardHistoryRequestTest { @Test fun `buildStoreForwardHistoryRequest clamps non-positive parameters`() { val request = - MeshHistoryManager.buildStoreForwardHistoryRequest( + HistoryManagerImpl.buildStoreForwardHistoryRequest( lastRequest = 0, historyReturnWindow = -1, historyReturnMax = 0, @@ -54,7 +54,7 @@ class StoreForwardHistoryRequestTest { @Test fun `resolveHistoryRequestParameters uses config values when positive`() { - val (window, max) = MeshHistoryManager.resolveHistoryRequestParameters(window = 30, max = 10) + val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 30, max = 10) assertEquals(30, window) assertEquals(10, max) @@ -62,7 +62,7 @@ class StoreForwardHistoryRequestTest { @Test fun `resolveHistoryRequestParameters falls back to defaults when non-positive`() { - val (window, max) = MeshHistoryManager.resolveHistoryRequestParameters(window = 0, max = -5) + val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 0, max = -5) assertEquals(1440, window) assertEquals(100, max) diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt similarity index 73% rename from app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index cefdb7b619..c21b43c698 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -14,15 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service - -import android.content.Context -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.updateAll -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import com.geeksville.mesh.repository.radio.RadioInterfaceService +package org.meshtastic.core.data.manager + import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -39,16 +32,27 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.meshtastic.core.analytics.platform.PlatformAnalytics -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node import org.meshtastic.core.prefs.ui.UiPrefs -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker +import org.meshtastic.core.repository.AppWidgetUpdater +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshWorkerManager +import org.meshtastic.core.repository.MqttManager +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.resources.getString import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig @@ -56,53 +60,54 @@ import org.meshtastic.proto.LocalStats import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.ToRadio -class MeshConnectionManagerTest { +class MeshConnectionManagerImplTest { - private val context: Context = mockk(relaxed = true) private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true) - private val connectionStateHolder = ConnectionStateHandler() - private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true) + private val serviceRepository: ServiceRepository = mockk(relaxed = true) + private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true) private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true) private val uiPrefs: UiPrefs = mockk(relaxed = true) private val packetHandler: PacketHandler = mockk(relaxed = true) private val nodeRepository: NodeRepository = mockk(relaxed = true) private val locationManager: MeshLocationManager = mockk(relaxed = true) - private val mqttManager: MeshMqttManager = mockk(relaxed = true) - private val historyManager: MeshHistoryManager = mockk(relaxed = true) + private val mqttManager: MqttManager = mockk(relaxed = true) + private val historyManager: HistoryManager = mockk(relaxed = true) private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true) - private val commandSender: MeshCommandSender = mockk(relaxed = true) - private val nodeManager: MeshNodeManager = mockk(relaxed = true) + private val commandSender: CommandSender = mockk(relaxed = true) + private val nodeManager: NodeManager = mockk(relaxed = true) private val analytics: PlatformAnalytics = mockk(relaxed = true) private val packetRepository: PacketRepository = mockk(relaxed = true) - private val workManager: WorkManager = mockk(relaxed = true) + private val workerManager: MeshWorkerManager = mockk(relaxed = true) + private val appWidgetUpdater: AppWidgetUpdater = mockk(relaxed = true) + private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected) + private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) private val localConfigFlow = MutableStateFlow(LocalConfig()) private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig()) private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var manager: MeshConnectionManager + private lateinit var manager: MeshConnectionManagerImpl @Before fun setUp() { - mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") - mockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt") - coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String" - coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } returns "Mocked String" - coEvery { any().updateAll(any()) } returns Unit + mockkStatic("org.meshtastic.core.resources.ContextExtKt") + every { getString(any()) } returns "Mocked String" + every { getString(any(), *anyVararg()) } returns "Mocked String" every { radioInterfaceService.connectionState } returns radioConnectionState every { radioConfigRepository.localConfigFlow } returns localConfigFlow every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null) every { nodeRepository.localStats } returns MutableStateFlow(LocalStats()) + every { serviceRepository.connectionState } returns connectionStateFlow + every { serviceRepository.setConnectionState(any()) } answers { connectionStateFlow.value = firstArg() } manager = - MeshConnectionManager( - context, + MeshConnectionManagerImpl( radioInterfaceService, - connectionStateHolder, + serviceRepository, serviceBroadcasts, serviceNotifications, uiPrefs, @@ -116,14 +121,14 @@ class MeshConnectionManagerTest { nodeManager, analytics, packetRepository, - workManager, + workerManager, + appWidgetUpdater, ) } @After fun tearDown() { - unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt") - unmockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt") + unmockkStatic("org.meshtastic.core.resources.ContextExtKt") } @Test @@ -135,7 +140,7 @@ class MeshConnectionManagerTest { assertEquals( "State should be Connecting after radio Connected", ConnectionState.Connecting, - connectionStateHolder.connectionState.value, + serviceRepository.connectionState.value, ) verify { serviceBroadcasts.broadcastConnection() } verify { packetHandler.sendToRadio(any()) } @@ -154,7 +159,7 @@ class MeshConnectionManagerTest { assertEquals( "State should be Disconnected after radio Disconnected", ConnectionState.Disconnected, - connectionStateHolder.connectionState.value, + serviceRepository.connectionState.value, ) verify { packetHandler.stopPacketQueue() } verify { locationManager.stop() } @@ -180,7 +185,7 @@ class MeshConnectionManagerTest { assertEquals( "State should be Disconnected when power saving is off", ConnectionState.Disconnected, - connectionStateHolder.connectionState.value, + serviceRepository.connectionState.value, ) } @@ -199,7 +204,7 @@ class MeshConnectionManagerTest { assertEquals( "State should stay in DeviceSleep when power saving is on", ConnectionState.DeviceSleep, - connectionStateHolder.connectionState.value, + serviceRepository.connectionState.value, ) } @@ -214,13 +219,7 @@ class MeshConnectionManagerTest { manager.onRadioConfigLoaded() advanceUntilIdle() - verify { - workManager.enqueueUniqueWork( - match { it.startsWith(SendMessageWorker.WORK_NAME_PREFIX) }, - any(), - any(), - ) - } + verify { workerManager.enqueueSendMessage(packetId) } verify { commandSender.sendAdmin(any(), initFn = any()) } } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataHandlerTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt similarity index 72% rename from app/src/test/java/com/geeksville/mesh/service/MeshDataHandlerTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 1314ddb7e1..0c133b36f3 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshDataHandlerTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import dagger.Lazy import io.mockk.coVerify @@ -29,14 +29,24 @@ import okio.ByteString.Companion.toByteString import org.junit.Before import org.junit.Test import org.meshtastic.core.analytics.platform.PlatformAnalytics -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.prefs.mesh.MeshPrefs -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.core.service.filter.MessageFilterService +import org.meshtastic.core.model.util.MeshDataMapper +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.HistoryManager +import org.meshtastic.core.repository.MeshConfigFlowManager +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MessageFilter +import org.meshtastic.core.repository.NeighborInfoHandler +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum @@ -44,27 +54,29 @@ import org.meshtastic.proto.StoreForwardPlusPlus class MeshDataHandlerTest { - private val nodeManager: MeshNodeManager = mockk(relaxed = true) + private val nodeManager: NodeManager = mockk(relaxed = true) private val packetHandler: PacketHandler = mockk(relaxed = true) private val serviceRepository: ServiceRepository = mockk(relaxed = true) private val packetRepository: PacketRepository = mockk(relaxed = true) private val packetRepositoryLazy: Lazy = mockk { every { get() } returns packetRepository } - private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true) + private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true) private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true) private val analytics: PlatformAnalytics = mockk(relaxed = true) private val dataMapper: MeshDataMapper = mockk(relaxed = true) private val configHandler: MeshConfigHandler = mockk(relaxed = true) + private val configHandlerLazy: Lazy = mockk { every { get() } returns configHandler } private val configFlowManager: MeshConfigFlowManager = mockk(relaxed = true) - private val commandSender: MeshCommandSender = mockk(relaxed = true) - private val historyManager: MeshHistoryManager = mockk(relaxed = true) - private val meshPrefs: MeshPrefs = mockk(relaxed = true) + private val configFlowManagerLazy: Lazy = mockk { every { get() } returns configFlowManager } + private val commandSender: CommandSender = mockk(relaxed = true) + private val historyManager: HistoryManager = mockk(relaxed = true) private val connectionManager: MeshConnectionManager = mockk(relaxed = true) - private val tracerouteHandler: MeshTracerouteHandler = mockk(relaxed = true) - private val neighborInfoHandler: MeshNeighborInfoHandler = mockk(relaxed = true) + private val connectionManagerLazy: Lazy = mockk { every { get() } returns connectionManager } + private val tracerouteHandler: TracerouteHandler = mockk(relaxed = true) + private val neighborInfoHandler: NeighborInfoHandler = mockk(relaxed = true) private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true) - private val messageFilterService: MessageFilterService = mockk(relaxed = true) + private val messageFilter: MessageFilter = mockk(relaxed = true) - private lateinit var meshDataHandler: MeshDataHandler + private lateinit var meshDataHandler: MeshDataHandlerImpl @OptIn(ExperimentalCoroutinesApi::class) @Before @@ -76,7 +88,7 @@ class MeshDataHandlerTest { every { android.util.Log.e(any(), any()) } returns 0 meshDataHandler = - MeshDataHandler( + MeshDataHandlerImpl( nodeManager, packetHandler, serviceRepository, @@ -85,16 +97,15 @@ class MeshDataHandlerTest { serviceNotifications, analytics, dataMapper, - configHandler, - configFlowManager, + configHandlerLazy, + configFlowManagerLazy, commandSender, historyManager, - meshPrefs, - connectionManager, + connectionManagerLazy, tracerouteHandler, neighborInfoHandler, radioConfigRepository, - messageFilterService, + messageFilter, ) // Use UnconfinedTestDispatcher for running coroutines synchronously in tests meshDataHandler.start(CoroutineScope(UnconfinedTestDispatcher())) diff --git a/core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt similarity index 94% rename from core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt index 4d99605738..65c77ec7ed 100644 --- a/core/service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt @@ -14,7 +14,7 @@ * 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 +package org.meshtastic.core.data.manager import io.mockk.every import io.mockk.mockk @@ -24,9 +24,9 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.prefs.filter.FilterPrefs -class MessageFilterServiceTest { +class MessageFilterImplTest { private lateinit var filterPrefs: FilterPrefs - private lateinit var filterService: MessageFilterService + private lateinit var filterService: MessageFilterImpl @Before fun setup() { @@ -34,7 +34,7 @@ class MessageFilterServiceTest { every { filterEnabled } returns true every { filterWords } returns setOf("spam", "bad") } - filterService = MessageFilterService(filterPrefs) + filterService = MessageFilterImpl(filterPrefs) } @Test diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt similarity index 80% rename from app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 6f32588a80..4748663ba3 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager import io.mockk.mockk import org.junit.Assert.assertEquals @@ -23,34 +23,35 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.service.MeshServiceNotifications +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.Position import org.meshtastic.proto.User -class MeshNodeManagerTest { +class NodeManagerImplTest { private val nodeRepository: NodeRepository = mockk(relaxed = true) - private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true) + private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true) private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true) - private lateinit var nodeManager: MeshNodeManager + private lateinit var nodeManager: NodeManagerImpl @Before fun setUp() { - nodeManager = MeshNodeManager(nodeRepository, serviceBroadcasts, serviceNotifications) + nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, serviceNotifications) } @Test - fun `getOrCreateNodeInfo creates default user for unknown node`() { + fun `getOrCreateNode creates default user for unknown node`() { val nodeNum = 1234 - val result = nodeManager.getOrCreateNodeInfo(nodeNum) + val result = nodeManager.getOrCreateNode(nodeNum) assertNotNull(result) assertEquals(nodeNum, result.num) - assertTrue(result.user.long_name?.startsWith("Meshtastic") == true) + assertTrue(result.user.long_name.startsWith("Meshtastic")) assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id) } @@ -61,7 +62,7 @@ class MeshNodeManagerTest { User(id = "!12345678", long_name = "My Custom Name", short_name = "MCN", hw_model = HardwareModel.TLORA_V2) // Setup existing node - nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser } + nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) } val incomingDefaultUser = User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET) @@ -79,7 +80,7 @@ class MeshNodeManagerTest { val existingUser = User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET) - nodeManager.updateNodeInfo(nodeNum) { it.user = existingUser } + nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) } val incomingDetailedUser = User(id = "!12345678", long_name = "Real User", short_name = "RU", hw_model = HardwareModel.TLORA_V1) @@ -96,7 +97,7 @@ class MeshNodeManagerTest { val nodeNum = 1234 val position = Position(latitude_i = 450000000, longitude_i = 900000000) - nodeManager.handleReceivedPosition(nodeNum, 9999, position) + nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0) val result = nodeManager.nodeDBbyNodeNum[nodeNum] assertNotNull(result!!.position) @@ -106,7 +107,7 @@ class MeshNodeManagerTest { @Test fun `clear resets internal state`() { - nodeManager.updateNodeInfo(1234) { it.longName = "Test" } + nodeManager.updateNode(1234) { it.copy(user = it.user.copy(long_name = "Test")) } nodeManager.clear() assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty()) diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt similarity index 75% rename from app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt rename to core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index bd3ddc0b9a..4447ec4409 100644 --- a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -14,9 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.data.manager -import com.geeksville.mesh.repository.radio.RadioInterfaceService import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -28,37 +27,44 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceBroadcasts +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.QueueStatus import org.meshtastic.proto.ToRadio -class PacketHandlerTest { +class PacketHandlerImplTest { private val packetRepository: PacketRepository = mockk(relaxed = true) - private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true) + private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true) private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true) private val meshLogRepository: MeshLogRepository = mockk(relaxed = true) - private val connectionStateHolder: ConnectionStateHandler = mockk(relaxed = true) + private val serviceRepository: ServiceRepository = mockk(relaxed = true) + private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) - private lateinit var handler: PacketHandler + private lateinit var handler: PacketHandlerImpl @Before fun setUp() { + every { serviceRepository.connectionState } returns connectionStateFlow + every { serviceRepository.setConnectionState(any()) } answers { connectionStateFlow.value = firstArg() } + handler = - PacketHandler( - dagger.Lazy { packetRepository }, + PacketHandlerImpl( + { packetRepository }, serviceBroadcasts, radioInterfaceService, - dagger.Lazy { meshLogRepository }, - connectionStateHolder, + { meshLogRepository }, + serviceRepository, ) handler.start(testScope) } @@ -75,7 +81,7 @@ class PacketHandlerTest { @Test fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) { val packet = MeshPacket(id = 456) - every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected) + connectionStateFlow.value = ConnectionState.Connected handler.sendToRadio(packet) testScheduler.runCurrent() @@ -86,7 +92,7 @@ class PacketHandlerTest { @Test fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) { val packet = MeshPacket(id = 789) - every { connectionStateHolder.connectionState } returns MutableStateFlow(ConnectionState.Connected) + connectionStateFlow.value = ConnectionState.Connected handler.sendToRadio(packet) testScheduler.runCurrent() diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt index a97f27a560..a5cee75e8b 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt @@ -41,7 +41,7 @@ class DeviceHardwareRepositoryTest { private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) private val repository = - DeviceHardwareRepository( + DeviceHardwareRepositoryImpl( remoteDataSource, localDataSource, jsonDataSource, diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt index 521cc22288..78c56d8c10 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 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 diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt index 17e48b2be3..978682f9f2 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 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 @@ -91,7 +91,7 @@ class NodeRepositoryTest { myNodeInfoFlow.value = createMyNodeEntity(myNodeNum) val repository = - NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource) + NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource) testScheduler.runCurrent() val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first() @@ -106,7 +106,7 @@ class NodeRepositoryTest { myNodeInfoFlow.value = createMyNodeEntity(myNodeNum) val repository = - NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource) + NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource) testScheduler.runCurrent() val result = repository.effectiveLogNodeId(remoteNodeNum).first() @@ -122,7 +122,7 @@ class NodeRepositoryTest { myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum) val repository = - NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource) + NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource) testScheduler.runCurrent() // Initially should be mapped to LOCAL because it matches diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 5c5ed5dcb7..cb85f50174 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -32,6 +32,7 @@ configure { } dependencies { + implementation(projects.core.repository) implementation(projects.core.common) implementation(projects.core.di) implementation(projects.core.model) diff --git a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt index 4ca6e26f73..e59e01c37e 100644 --- a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt +++ b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt @@ -34,8 +34,8 @@ import org.junit.runner.RunWith import org.meshtastic.core.database.MeshtasticDatabase import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.NodeSortOption +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.User diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt index fe90c72e3c..e935a88e21 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -41,6 +41,7 @@ import java.io.File import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton +import org.meshtastic.core.repository.DatabaseManager as SharedDatabaseManager /** Manages per-device Room database instances for node data, with LRU eviction. */ @Singleton @@ -51,21 +52,21 @@ open class DatabaseManager constructor( private val app: Application, private val dispatchers: CoroutineDispatchers, -) { +) : SharedDatabaseManager { val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE) private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default) private val mutex = Mutex() // Expose the DB cache limit as a reactive stream so UI can observe changes. - private val _cacheLimit = MutableStateFlow(getCacheLimit()) - open val cacheLimit: StateFlow = _cacheLimit + private val _cacheLimit = MutableStateFlow(getCurrentCacheLimit()) + override val cacheLimit: StateFlow = _cacheLimit // Keep cache-limit StateFlow in sync if some other component updates SharedPreferences. private val prefsListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == DatabaseConstants.CACHE_LIMIT_KEY) { - _cacheLimit.value = getCacheLimit() + _cacheLimit.value = getCurrentCacheLimit() } } @@ -88,7 +89,7 @@ constructor( } /** Switch active database to the one associated with [address]. Serialized via mutex. */ - suspend fun switchActiveDatabase(address: String?) = mutex.withLock { + override suspend fun switchActiveDatabase(address: String?) = mutex.withLock { val dbName = buildDbName(address) // Remember the previously active DB name (any) so we can record its last-used time as well. @@ -159,7 +160,7 @@ constructor( } private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock { - val limit = getCacheLimit() + val limit = getCurrentCacheLimit() val all = listExistingDbNames() // Only enforce the limit over device-specific DBs; exclude legacy and default DBs val deviceDbs = @@ -189,13 +190,13 @@ constructor( } } - fun getCacheLimit(): Int = prefs + override fun getCurrentCacheLimit(): Int = prefs .getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT) .coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT) - fun setCacheLimit(limit: Int) { + override fun setCacheLimit(limit: Int) { val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT) - if (clamped == getCacheLimit()) return + if (clamped == getCurrentCacheLimit()) return prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply() _cacheLimit.value = clamped // Enforce asynchronously with current active DB protected 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 987ed999fa..047b2b47c7 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 @@ -241,17 +241,19 @@ interface PacketDao { @Transaction suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) { val new = data.copy(status = m) - // Find by packet ID first for better performance and reliability - findPacketsWithId(data.id).find { it.data == data }?.let { update(it.copy(data = new)) } - ?: findDataPacket(data)?.let { update(it.copy(data = new)) } + // Match on key fields that identify the packet, rather than the entire data object + findPacketsWithId(data.id) + .find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } + ?.let { update(it.copy(data = new)) } } @Transaction suspend fun updateMessageId(data: DataPacket, id: Int) { val new = data.copy(id = id) - // Find by packet ID first for better performance and reliability - findPacketsWithId(data.id).find { it.data == data }?.let { update(it.copy(data = new, packetId = id)) } - ?: findDataPacket(data)?.let { update(it.copy(data = new, packetId = id)) } + // Match on key fields that identify the packet + findPacketsWithId(data.id) + .find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } + ?.let { update(it.copy(data = new, packetId = id)) } } @Query( diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt index b79c7c180a..8a722aa6c9 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.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,14 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.database.di import android.app.Application +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.MeshtasticDatabase import org.meshtastic.core.database.dao.DeviceHardwareDao import org.meshtastic.core.database.dao.FirmwareReleaseDao @@ -34,26 +35,34 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -class DatabaseModule { - @Provides @Singleton - fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app) +abstract class DatabaseModule { + + @Binds + @Singleton + abstract fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager + + companion object { + @Provides + @Singleton + fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app) - @Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao() + @Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao() - @Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao() + @Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao() - @Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao() + @Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao() - @Provides - fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao() + @Provides + fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao() - @Provides - fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao() + @Provides + fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao() - @Provides - fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao() + @Provides + fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao() - @Provides - fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao = - database.tracerouteNodePositionDao() + @Provides + fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao = + database.tracerouteNodePositionDao() + } } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 69b3263102..6a47232bff 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -26,10 +26,10 @@ import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceMetrics import org.meshtastic.core.model.EnvironmentMetrics import org.meshtastic.core.model.MeshUser +import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.onlineTimeThreshold @@ -65,6 +65,7 @@ data class NodeWithRelations( environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(), paxcounter = paxcounter, + publicKey = publicKey ?: user.public_key, notes = notes, manuallyVerified = manuallyVerified, nodeStatus = nodeStatus, @@ -90,6 +91,7 @@ data class NodeWithRelations( environmentTelemetry = environmentTelemetry, powerTelemetry = powerTelemetry, paxcounter = paxcounter, + publicKey = publicKey ?: user.public_key, notes = notes, manuallyVerified = manuallyVerified, nodeStatus = nodeStatus, 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 c522a22dba..5529b9606c 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 @@ -24,12 +24,12 @@ import androidx.room.PrimaryKey import androidx.room.Relation import okio.ByteString import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.database.model.Message -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction import org.meshtastic.core.model.util.getShortDateTime -import org.meshtastic.proto.User data class PacketEntity( @Embedded val packet: Packet, @@ -130,24 +130,6 @@ data class ContactSettings( get() = nowMillis <= muteUntil } -data class Reaction( - val replyId: Int, - val user: User, - val emoji: String, - val timestamp: Long, - val snr: Float, - val rssi: Int, - val hopsAway: Int, - val packetId: Int = 0, - val status: MessageStatus = MessageStatus.UNKNOWN, - val routingError: Int = 0, - val relays: Int = 0, - val relayNode: Int? = null, - val to: String? = null, - val channel: Int = 0, - val sfppHash: ByteString? = null, -) - @Suppress("ConstructorParameterNaming") @Entity( tableName = "reactions", @@ -173,11 +155,11 @@ data class ReactionEntity( @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null, ) -private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction { - val node = getNode(userId) +suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node?): Reaction { + val user = getNode(userId)?.user ?: org.meshtastic.proto.User(id = userId) return Reaction( replyId = replyId, - user = node.user, + user = user, emoji = emoji, timestamp = timestamp, snr = snr, @@ -194,5 +176,5 @@ private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) ) } -private suspend fun List.toReaction(getNode: suspend (userId: String?) -> Node) = +suspend fun List.toReaction(getNode: suspend (userId: String?) -> Node?) = this.map { it.toReaction(getNode) } diff --git a/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt b/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt index 5a4db388ec..aad9defe18 100644 --- a/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt +++ b/core/database/src/test/kotlin/org/meshtastic/core/database/model/NodeTest.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.database.model +package org.meshtastic.core.model import org.junit.Assert.assertEquals import org.junit.Test diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 60226b6618..c368cd45de 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -24,6 +24,7 @@ plugins { android { namespace = "org.meshtastic.core.domain" } dependencies { + implementation(projects.core.repository) implementation(projects.core.model) implementation(projects.core.proto) implementation(projects.core.common) diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt index 728a209e4e..b0b7c2c8c6 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt @@ -16,11 +16,16 @@ */ package org.meshtastic.core.domain.usecase.settings -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository import javax.inject.Inject -/** Use case for performing administrative actions on the radio. */ +/** + * Use case for performing administrative and destructive actions on mesh nodes. + * + * This component provides methods for rebooting, shutting down, or resetting nodes within the mesh. It also handles + * local database synchronization when these actions are performed on the locally connected device. + */ open class AdminActionsUseCase @Inject constructor( diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 6a32f1131a..655323caf1 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -16,14 +16,14 @@ */ package org.meshtastic.core.domain.usecase.settings -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository import javax.inject.Inject import kotlin.time.Duration.Companion.days /** Use case for cleaning up nodes from the database. */ -class CleanNodeDatabaseUseCase +open class CleanNodeDatabaseUseCase @Inject constructor( private val nodeRepository: NodeRepository, @@ -43,11 +43,9 @@ constructor( nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt()) } - return nodesToConsider - .filterNot { node -> - (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite - } - .map { it.toModel() } + return nodesToConsider.filterNot { node -> + (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite + } } /** Performs the cleanup of specified nodes. */ diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt index c8bcdf699b..aea9301d4e 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt @@ -19,9 +19,9 @@ package org.meshtastic.core.domain.usecase.settings import android.icu.text.SimpleDateFormat import kotlinx.coroutines.flow.first import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.positionToMeter +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.proto.PortNum import java.io.BufferedWriter import java.util.Locale @@ -30,7 +30,7 @@ import kotlin.math.roundToInt import org.meshtastic.proto.Position as ProtoPosition /** Use case for exporting persisted packet data to a CSV format. */ -class ExportDataUseCase +open class ExportDataUseCase @Inject constructor( private val nodeRepository: NodeRepository, diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt index 8a9905975d..50d82d7444 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt @@ -21,7 +21,7 @@ import java.io.OutputStream import javax.inject.Inject /** Use case for exporting a device profile to an output stream. */ -class ExportProfileUseCase @Inject constructor() { +open class ExportProfileUseCase @Inject constructor() { /** * Exports the provided [DeviceProfile] to the given [OutputStream]. * diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt index 2e32ed868c..a48cc64779 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt @@ -24,7 +24,7 @@ import java.io.OutputStream import javax.inject.Inject /** Use case for exporting security configuration to a JSON format. */ -class ExportSecurityConfigUseCase @Inject constructor() { +open class ExportSecurityConfigUseCase @Inject constructor() { /** * Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream]. * diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt index 7dc1a97450..d78d716937 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt @@ -21,7 +21,7 @@ import java.io.InputStream import javax.inject.Inject /** Use case for importing a device profile from an input stream. */ -class ImportProfileUseCase @Inject constructor() { +open class ImportProfileUseCase @Inject constructor() { /** * Imports a [DeviceProfile] from the provided [InputStream]. * diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt index 20b59f452b..88e8319a54 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt @@ -27,7 +27,7 @@ import org.meshtastic.proto.User import javax.inject.Inject /** Use case for installing a device profile onto a radio. */ -class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) { +open class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) { /** * Installs the provided [DeviceProfile] onto the radio at [destNum]. * diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt index 0e18a33a78..f77a09345c 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt @@ -20,19 +20,19 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import org.meshtastic.core.data.repository.DeviceHardwareRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.prefs.radio.isBle import org.meshtastic.core.prefs.radio.isSerial import org.meshtastic.core.prefs.radio.isTcp +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.NodeRepository import javax.inject.Inject /** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */ -class IsOtaCapableUseCase +open class IsOtaCapableUseCase @Inject constructor( private val nodeRepository: NodeRepository, diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt index f03f89e230..6f578bc055 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt @@ -20,7 +20,7 @@ import org.meshtastic.core.model.RadioController import javax.inject.Inject /** Use case for controlling location sharing with the mesh. */ -class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) { +open class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) { /** Starts providing the phone's location to the mesh. */ fun startProvidingLocation() { radioController.startProvideLocation() diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt index e208a54353..3e16394699 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.domain.usecase.settings import co.touchlab.kermit.Logger -import org.meshtastic.core.database.model.getStringResFrom +import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.resources.UiText import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel @@ -54,7 +54,7 @@ sealed class RadioResponseResult { } /** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */ -class ProcessRadioResponseUseCase @Inject constructor() { +open class ProcessRadioResponseUseCase @Inject constructor() { /** * Decodes and processes the provided [packet]. * diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt index 04462c0f91..d31cc41f33 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt @@ -20,7 +20,11 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource import javax.inject.Inject /** Use case for setting whether the application intro has been completed. */ -class SetAppIntroCompletedUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) { +open class SetAppIntroCompletedUseCase +@Inject +constructor( + private val uiPreferencesDataSource: UiPreferencesDataSource, +) { operator fun invoke(completed: Boolean) { uiPreferencesDataSource.setAppIntroCompleted(completed) } diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt index 4153ad934d..42224e8498 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt @@ -17,11 +17,11 @@ package org.meshtastic.core.domain.usecase.settings import org.meshtastic.core.database.DatabaseConstants -import org.meshtastic.core.database.DatabaseManager +import org.meshtastic.core.repository.DatabaseManager import javax.inject.Inject /** Use case for setting the database cache limit. */ -class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) { +open class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) { operator fun invoke(limit: Int) { val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT) databaseManager.setCacheLimit(clamped) diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt index 360c72bcdd..cdb822dde1 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt @@ -21,7 +21,7 @@ import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import javax.inject.Inject /** Use case for managing mesh log settings. */ -class SetMeshLogSettingsUseCase +open class SetMeshLogSettingsUseCase @Inject constructor( private val meshLogRepository: MeshLogRepository, diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt index fa8daee9e2..3a45c3e430 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt @@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.ui.UiPrefs import javax.inject.Inject /** Use case for setting whether to provide the node location to the mesh. */ -class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) { +open class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) { operator fun invoke(myNodeNum: Int, provideLocation: Boolean) { uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation) } diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt index 437e396044..fd1ae35a0f 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt @@ -20,7 +20,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource import javax.inject.Inject /** Use case for setting the application theme. */ -class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) { +open class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) { operator fun invoke(themeMode: Int) { uiPreferencesDataSource.setTheme(themeMode) } diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt index 0682c4da2d..b8e6f2d295 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt @@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.analytics.AnalyticsPrefs import javax.inject.Inject /** Use case for toggling the analytics preference. */ -class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) { +open class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) { operator fun invoke() { analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed } diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt index 1c83d68862..f42dee80b5 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt +++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt @@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs import javax.inject.Inject /** Use case for toggling the homoglyph encoding preference. */ -class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) { +open class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) { operator fun invoke() { homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled } diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt index 69ec2022a2..115f4ff434 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt @@ -53,6 +53,10 @@ class FakeRadioController : RadioController { sentSharedContacts.add(nodeNum) } + override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {} + + override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {} + override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {} override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {} @@ -83,6 +87,10 @@ class FakeRadioController : RadioController { override suspend fun reboot(destNum: Int, packetId: Int) {} + override suspend fun rebootToDfu(nodeNum: Int) {} + + override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} + override suspend fun shutdown(destNum: Int, packetId: Int) {} override suspend fun factoryReset(destNum: Int, packetId: Int) {} @@ -91,6 +99,16 @@ class FakeRadioController : RadioController { override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {} + override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {} + + override suspend fun requestUserInfo(destNum: Int) {} + + override suspend fun requestTraceroute(requestId: Int, destNum: Int) {} + + override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {} + + override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {} + override suspend fun beginEditSettings(destNum: Int) {} override suspend fun commitEditSettings(destNum: Int) {} @@ -101,6 +119,8 @@ class FakeRadioController : RadioController { override fun stopProvideLocation() {} + override fun setDeviceAddress(address: String) {} + // --- Helper methods for testing --- fun setConnectionState(state: ConnectionState) { diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt index 6c0d0fe6e2..fac5b04e43 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt @@ -29,15 +29,15 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.model.Node import org.meshtastic.core.domain.FakeRadioController -import org.meshtastic.core.domain.MessageQueue import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MessageQueue +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.usecase.SendMessageUseCase import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata @@ -90,7 +90,7 @@ class SendMessageUseCaseTest { assertEquals(0, radioController.favoritedNodes.size) assertEquals(0, radioController.sentSharedContacts.size) - coVerify { packetRepository.insert(any()) } + coVerify { packetRepository.savePacket(any(), any(), any(), any()) } coVerify { messageQueue.enqueue(any()) } } @@ -120,7 +120,7 @@ class SendMessageUseCaseTest { assertEquals(1, radioController.favoritedNodes.size) assertEquals(12345, radioController.favoritedNodes[0]) - coVerify { packetRepository.insert(any()) } + coVerify { packetRepository.savePacket(any(), any(), any(), any()) } coVerify { messageQueue.enqueue(any()) } } @@ -149,7 +149,7 @@ class SendMessageUseCaseTest { assertEquals(1, radioController.sentSharedContacts.size) assertEquals(67890, radioController.sentSharedContacts[0]) - coVerify { packetRepository.insert(any()) } + coVerify { packetRepository.savePacket(any(), any(), any(), any()) } coVerify { messageQueue.enqueue(any()) } } @@ -166,9 +166,9 @@ class SendMessageUseCaseTest { useCase(originalText, "0${DataPacket.ID_BROADCAST}", null) // Assert - val packetSlot = slot() - coVerify { packetRepository.insert(capture(packetSlot)) } - assertTrue(packetSlot.captured.data?.text?.contains("Apple") == true) + val packetSlot = slot() + coVerify { packetRepository.savePacket(any(), any(), capture(packetSlot), any()) } + assertTrue(packetSlot.captured.text?.contains("Apple") == true) coVerify { messageQueue.enqueue(any()) } } } diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt index e423ca882e..a6fe77b73b 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt @@ -23,8 +23,8 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository class AdminActionsUseCaseTest { diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt index 001c0a5fec..e8631beb26 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt @@ -23,9 +23,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.domain.FakeRadioController +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeRepository import kotlin.time.Duration.Companion.days class CleanNodeDatabaseUseCaseTest { @@ -47,9 +47,9 @@ class CleanNodeDatabaseUseCaseTest { val currentTime = 1000000L val olderThanTimestamp = currentTime - 30.days.inWholeSeconds - val oldNode = NodeEntity(num = 1, lastHeard = (olderThanTimestamp - 1).toInt()) - val newNode = NodeEntity(num = 2, lastHeard = (currentTime - 1).toInt()) - val ignoredNode = NodeEntity(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true) + val oldNode = Node(num = 1, lastHeard = (olderThanTimestamp - 1).toInt()) + val newNode = Node(num = 2, lastHeard = (currentTime - 1).toInt()) + val ignoredNode = Node(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true) coEvery { nodeRepository.getNodesOlderThan(any()) } returns listOf(oldNode, ignoredNode) diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt index 32dcff37f6..5e3a05cabb 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt @@ -27,9 +27,9 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket @@ -63,7 +63,6 @@ class ExportDataUseCaseTest { val nodes = mapOf(senderNodeNum to senderNode) val stateFlow = MutableStateFlow(nodes) every { nodeRepository.nodeDBbyNum } returns stateFlow - every { nodeRepository.getNodeEntityDBbyNumFlow() } returns flowOf(emptyMap()) val meshPacket = MeshPacket( diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt index 41db758c71..8e6b21077c 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt @@ -26,12 +26,12 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.DeviceHardwareRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.radio.RadioPrefs +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.NodeRepository class IsOtaCapableUseCaseTest { diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt index 1551ab32d9..78a22de2fe 100644 --- a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt +++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt @@ -21,7 +21,7 @@ import io.mockk.verify import org.junit.Before import org.junit.Test import org.meshtastic.core.database.DatabaseConstants -import org.meshtastic.core.database.DatabaseManager +import org.meshtastic.core.repository.DatabaseManager class SetDatabaseCacheLimitUseCaseTest { diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 9514039764..d1e6008183 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -36,11 +36,13 @@ kotlin { commonMain.dependencies { api(projects.core.proto) api(projects.core.common) + api(projects.core.resources) api(libs.kotlinx.serialization.json) api(libs.kotlinx.datetime) implementation(libs.kermit) api(libs.okio) + api(libs.compose.multiplatform.resources) } androidMain.dependencies { api(libs.androidx.annotation) diff --git a/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt index 1ebc7faf24..486ef4368f 100644 --- a/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt +++ b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt @@ -31,7 +31,7 @@ class ChannelSetTest { val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ") val cs = url.toChannelSet() Assert.assertEquals("LongFast", cs.primaryChannel!!.name) - Assert.assertEquals(url, cs.getChannelUrl(false)) + Assert.assertEquals(url.toString(), cs.getChannelUrl(false).toString()) } /** validate against the host or path in a case-insensitive way */ diff --git a/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt index 8f346ed2fe..fc877497f6 100644 --- a/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt +++ b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt @@ -56,11 +56,43 @@ class SharedContactTest { assertEquals("Suzume", contact.user?.long_name) } - @Test(expected = java.net.MalformedURLException::class) + @Test(expected = MalformedMeshtasticUrlException::class) fun testInvalidHostThrows() { val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345) val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com") val url = Uri.parse(urlStr) url.toSharedContact() } + + @Test(expected = MalformedMeshtasticUrlException::class) + fun testInvalidPathThrows() { + val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345) + val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/") + val url = Uri.parse(urlStr) + url.toSharedContact() + } + + @Test(expected = MalformedMeshtasticUrlException::class) + fun testMissingFragmentThrows() { + val urlStr = "https://meshtastic.org/v/" + val url = Uri.parse(urlStr) + url.toSharedContact() + } + + @Test(expected = MalformedMeshtasticUrlException::class) + fun testInvalidBase64Throws() { + val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!" + val url = Uri.parse(urlStr) + url.toSharedContact() + } + + @Test(expected = MalformedMeshtasticUrlException::class) + fun testInvalidProtoThrows() { + // Tag 0 is invalid in Protobuf + // 0x00 -> Tag 0, Type 0. + // Base64 for 0x00 is "AA==" + val urlStr = "https://meshtastic.org/v/#AA==" + val url = Uri.parse(urlStr) + url.toSharedContact() + } } diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt similarity index 70% rename from app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt index 5b01cbed3e..e9403ce856 100644 --- a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt +++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2026 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,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.model.util import io.mockk.every import io.mockk.mockk @@ -31,36 +31,12 @@ import org.meshtastic.proto.PortNum class MeshDataMapperTest { - private val nodeManager: MeshNodeManager = mockk() + private val nodeIdLookup: NodeIdLookup = mockk() private lateinit var mapper: MeshDataMapper @Before fun setUp() { - mapper = MeshDataMapper(nodeManager) - } - - @Test - fun `toNodeID resolves broadcast correctly`() { - every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST - assertEquals(DataPacket.ID_BROADCAST, mapper.toNodeID(DataPacket.NODENUM_BROADCAST)) - } - - @Test - fun `toNodeID resolves known node correctly`() { - val nodeNum = 1234 - val nodeId = "!1234abcd" - every { nodeManager.toNodeID(nodeNum) } returns nodeId - - assertEquals(nodeId, mapper.toNodeID(nodeNum)) - } - - @Test - fun `toNodeID resolves unknown node to default ID`() { - val nodeNum = 1234 - val nodeId = DataPacket.nodeNumToDefaultId(nodeNum) - every { nodeManager.toNodeID(nodeNum) } returns nodeId - - assertEquals(nodeId, mapper.toNodeID(nodeNum)) + mapper = MeshDataMapper(nodeIdLookup) } @Test @@ -73,8 +49,8 @@ class MeshDataMapperTest { fun `toDataPacket maps basic fields correctly`() { val nodeNum = 1234 val nodeId = "!1234abcd" - every { nodeManager.toNodeID(nodeNum) } returns nodeId - every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST + every { nodeIdLookup.toNodeID(nodeNum) } returns nodeId + every { nodeIdLookup.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST val proto = MeshPacket( @@ -111,7 +87,7 @@ class MeshDataMapperTest { fun `toDataPacket maps PKC channel correctly for encrypted packets`() { val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data()) - every { nodeManager.toNodeID(any()) } returns "any" + every { nodeIdLookup.toNodeID(any()) } returns "any" val result = mapper.toDataPacket(proto) assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt index a013005dfd..0a9ad17483 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt @@ -103,9 +103,6 @@ enum class RegionInfo( val freqEnd: Float, val wideLora: Boolean = false, ) { - /** This needs to be last. Same as US. */ - UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f), - /** * United States * @@ -288,6 +285,9 @@ enum class RegionInfo( * @see [Firmware Issue #7399](https://github.com/meshtastic/firmware/pull/7399) */ BR_902(RegionCode.BR_902, "Brazil 902MHz", 902.0f, 907.5f, wideLora = false), + + /** This needs to be last. Same as US. */ + UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f), ; companion object { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt index 7df9f63af8..197f5e9d16 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt @@ -32,3 +32,12 @@ data class Contact( val isUnmessageable: Boolean, val nodeColors: Pair? = null, ) : CommonParcelable + +data class ContactSettings( + val contactKey: String, + val muteUntil: Long = 0L, + val lastReadMessageUuid: Long? = null, + val lastReadMessageTimestamp: Long? = null, + val filteringDisabled: Boolean = false, + val isMuted: Boolean = false, +) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceId.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/InterfaceId.kt similarity index 74% rename from app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceId.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/InterfaceId.kt index 1081394edb..a89f706d96 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceId.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/InterfaceId.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,12 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +package org.meshtastic.core.model -package com.geeksville.mesh.repository.radio - -/** - * Address identifiers for all supported radio backend implementations. - */ +/** Address identifiers for all supported radio backend implementations. */ enum class InterfaceId(val id: Char) { BLUETOOTH('x'), MOCK('m'), @@ -29,8 +26,6 @@ enum class InterfaceId(val id: Char) { ; companion object { - fun forIdChar(id: Char): InterfaceId? { - return entries.firstOrNull { it.id == id } - } + fun forIdChar(id: Char): InterfaceId? = entries.firstOrNull { it.id == id } } -} \ No newline at end of file +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt new file mode 100644 index 0000000000..8b94a9fe0b --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt @@ -0,0 +1,26 @@ +/* + * 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.model + +/** Represents activity on the mesh network. */ +sealed class MeshActivity { + /** Data is being sent to the radio. */ + data object Send : MeshActivity() + + /** Data is being received from the radio. */ + data object Receive : MeshActivity() +} diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt similarity index 97% rename from core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt index 3205c05298..0dd87b399e 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt @@ -14,11 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.database.model +package org.meshtastic.core.model import org.jetbrains.compose.resources.StringResource -import org.meshtastic.core.database.entity.Reaction -import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.delivery_confirmed import org.meshtastic.core.resources.error diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt similarity index 87% rename from core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 64cc0c1011..b7f2dd31a2 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -14,16 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.database.model +package org.meshtastic.core.model import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.GPSFormat import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.latLongToMeter -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.model.Capabilities -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit +import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata @@ -34,7 +33,6 @@ import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Paxcount import org.meshtastic.proto.Position import org.meshtastic.proto.PowerMetrics -import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User /** @@ -70,6 +68,9 @@ data class Node( ) { val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) } + val isOnline: Boolean + get() = lastHeard > onlineTimeThreshold() + val colors: Pair get() { // returns foreground and background @ColorInt for each 'num' val r = (num and 0xFF0000) shr 16 @@ -88,7 +89,7 @@ data class Node( get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true val mismatchKey - get() = (publicKey ?: user.public_key) == NodeEntity.ERROR_BYTE_STRING + get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING val hasEnvironmentMetrics: Boolean get() = environmentMetrics != EnvironmentMetrics() @@ -137,6 +138,7 @@ data class Node( fun gpsString(): String = GPSFormat.toDec(latitude, longitude) + @Suppress("CyclomaticComplexMethod") private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List { val temp = if ((temperature ?: 0f) != 0f) { @@ -188,34 +190,31 @@ data class Node( fun getTelemetryStrings(isFahrenheit: Boolean = false): List = environmentMetrics.getDisplayStrings(isFahrenheit) - fun toEntity() = NodeEntity( - num = num, - user = user, - position = position, - latitude = latitude, - longitude = longitude, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceTelemetry = Telemetry(device_metrics = deviceMetrics), - channel = channel, - viaMqtt = viaMqtt, - hopsAway = hopsAway, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - environmentTelemetry = Telemetry(environment_metrics = environmentMetrics), - powerTelemetry = Telemetry(power_metrics = powerMetrics), - paxcounter = paxcounter, - publicKey = publicKey ?: user.public_key, - notes = notes, - manuallyVerified = manuallyVerified, - nodeStatus = nodeStatus, - lastTransport = lastTransport, - ) - companion object { private const val DEFAULT_ID_SUFFIX_LENGTH = 4 + private const val RELAY_NODE_SUFFIX_MASK = 0xFF + + val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString() + + fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { + val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK + + val candidateRelayNodes = + nodes.filter { + it.num != ourNodeNum && + it.lastHeard != 0 && + (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } + + val closestRelayNode = + if (candidateRelayNodes.size == 1) { + candidateRelayNodes.first() + } else { + candidateRelayNodes.minByOrNull { it.hopsAway } + } + + return closestRelayNode + } /** Creates a fallback [Node] when the node is not found in the database. */ fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node { diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/NodeSortOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt similarity index 97% rename from core/database/src/main/kotlin/org/meshtastic/core/database/model/NodeSortOption.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt index c54a66b637..7e2757c066 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/NodeSortOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.database.model +package org.meshtastic.core.model import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.resources.Res diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 286f32ddbb..e021c0aa95 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -19,67 +19,299 @@ package org.meshtastic.core.model import kotlinx.coroutines.flow.StateFlow import org.meshtastic.proto.ClientNotification +/** + * Central interface for controlling the radio and mesh network. + * + * This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the + * low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about + * platform-specific service details or AIDL interfaces. + */ @Suppress("TooManyFunctions") interface RadioController { + /** Reactive connection state of the radio. */ val connectionState: StateFlow + + /** + * Flow of notifications from the radio client. + * + * These represent high-level events like "Handshake completed" or "Channel configuration updated." + */ val clientNotification: StateFlow + /** + * Sends a data packet to the mesh. + * + * @param packet The [DataPacket] containing the payload and routing information. + */ suspend fun sendMessage(packet: DataPacket) + /** Clears the current [clientNotification]. */ fun clearClientNotification() - // Abstracted ServiceActions + /** + * Toggles the favorite status of a node on the radio. + * + * @param nodeNum The node number to favorite/unfavorite. + */ suspend fun favoriteNode(nodeNum: Int) + /** + * Sends our shared contact information (identity and public key) to a remote node. + * + * @param nodeNum The destination node number. + */ suspend fun sendSharedContact(nodeNum: Int) - // Radio configuration + /** + * Updates the local radio configuration. + * + * @param config The new configuration [org.meshtastic.proto.Config]. + */ + suspend fun setLocalConfig(config: org.meshtastic.proto.Config) + + /** + * Updates a local radio channel. + * + * @param channel The channel configuration [org.meshtastic.proto.Channel]. + */ + suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) + + /** + * Updates the owner (user info) on a remote node. + * + * @param destNum The destination node number. + * @param user The new user info [org.meshtastic.proto.User]. + * @param packetId The request packet ID. + */ suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) + /** + * Updates the general configuration on a remote node. + * + * @param destNum The destination node number. + * @param config The new configuration [org.meshtastic.proto.Config]. + * @param packetId The request packet ID. + */ suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) + /** + * Updates a module configuration on a remote node. + * + * @param destNum The destination node number. + * @param config The new module configuration [org.meshtastic.proto.ModuleConfig]. + * @param packetId The request packet ID. + */ suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) + /** + * Updates a channel configuration on a remote node. + * + * @param destNum The destination node number. + * @param channel The new channel configuration [org.meshtastic.proto.Channel]. + * @param packetId The request packet ID. + */ suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) + /** + * Sets a fixed position on a remote node. + * + * @param destNum The destination node number. + * @param position The position to set. + */ suspend fun setFixedPosition(destNum: Int, position: Position) + /** + * Updates the notification ringtone on a remote node. + * + * @param destNum The destination node number. + * @param ringtone The name/ID of the ringtone. + */ suspend fun setRingtone(destNum: Int, ringtone: String) + /** + * Updates the canned messages configuration on a remote node. + * + * @param destNum The destination node number. + * @param messages The canned messages string. + */ suspend fun setCannedMessages(destNum: Int, messages: String) - // Admin get operations + /** + * Requests the current owner (user info) from a remote node. + * + * @param destNum The remote node number. + * @param packetId The request packet ID. + */ suspend fun getOwner(destNum: Int, packetId: Int) + /** + * Requests a specific configuration section from a remote node. + * + * @param destNum The remote node number. + * @param configType The numeric type of the configuration section. + * @param packetId The request packet ID. + */ suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) + /** + * Requests a module configuration section from a remote node. + * + * @param destNum The remote node number. + * @param moduleConfigType The numeric type of the module configuration section. + * @param packetId The request packet ID. + */ suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) + /** + * Requests a specific channel configuration from a remote node. + * + * @param destNum The remote node number. + * @param index The channel index. + * @param packetId The request packet ID. + */ suspend fun getChannel(destNum: Int, index: Int, packetId: Int) + /** + * Requests the current ringtone from a remote node. + * + * @param destNum The remote node number. + * @param packetId The request packet ID. + */ suspend fun getRingtone(destNum: Int, packetId: Int) + /** + * Requests the current canned messages from a remote node. + * + * @param destNum The remote node number. + * @param packetId The request packet ID. + */ suspend fun getCannedMessages(destNum: Int, packetId: Int) + /** + * Requests the hardware connection status from a remote node. + * + * @param destNum The remote node number. + * @param packetId The request packet ID. + */ suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) - // Admin operations + /** + * Commands a node to reboot. + * + * @param destNum The target node number. + * @param packetId The request packet ID. + */ suspend fun reboot(destNum: Int, packetId: Int) + /** + * Commands a node to reboot into DFU (Device Firmware Update) mode. + * + * @param nodeNum The target node number. + */ + suspend fun rebootToDfu(nodeNum: Int) + + /** + * Initiates an Over-The-Air (OTA) reboot request. + * + * @param requestId The request ID. + * @param destNum The target node number. + * @param mode The OTA mode. + * @param hash Optional hash for verification. + */ + suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) + + /** + * Commands a node to shut down. + * + * @param destNum The target node number. + * @param packetId The request packet ID. + */ suspend fun shutdown(destNum: Int, packetId: Int) + /** + * Performs a factory reset on a node. + * + * @param destNum The target node number. + * @param packetId The request packet ID. + */ suspend fun factoryReset(destNum: Int, packetId: Int) + /** + * Resets the NodeDB on a node. + * + * @param destNum The target node number. + * @param packetId The request packet ID. + * @param preserveFavorites Whether to keep favorite nodes in the database. + */ suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) + /** + * Removes a node from the mesh by its node number. + * + * @param packetId The request packet ID. + * @param nodeNum The node number to remove. + */ suspend fun removeByNodenum(packetId: Int, nodeNum: Int) - // Batch editing + /** + * Requests the current GPS position from a remote node. + * + * @param destNum The target node number. + * @param currentPosition Our current position to provide in the request. + */ + suspend fun requestPosition(destNum: Int, currentPosition: Position) + + /** + * Requests detailed user info from a remote node. + * + * @param destNum The target node number. + */ + suspend fun requestUserInfo(destNum: Int) + + /** + * Initiates a traceroute request to a remote node. + * + * @param requestId The request ID. + * @param destNum The destination node number. + */ + suspend fun requestTraceroute(requestId: Int, destNum: Int) + + /** + * Requests telemetry data from a remote node. + * + * @param requestId The request ID. + * @param destNum The destination node number. + * @param typeValue The numeric type of telemetry requested. + */ + suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) + + /** + * Requests neighbor information (detected nodes) from a remote node. + * + * @param requestId The request ID. + * @param destNum The destination node number. + */ + suspend fun requestNeighborInfo(requestId: Int, destNum: Int) + + /** + * Signals the start of a batch configuration session. + * + * @param destNum The target node number. + */ suspend fun beginEditSettings(destNum: Int) + /** + * Commits all pending configuration changes in a batch session. + * + * @param destNum The target node number. + */ suspend fun commitEditSettings(destNum: Int) - // Helpers + /** + * Generates a unique packet ID for a new request. + * + * @return A unique 32-bit integer. + */ fun getPacketId(): Int /** Starts providing the phone's location to the mesh. */ @@ -87,4 +319,11 @@ interface RadioController { /** Stops providing the phone's location to the mesh. */ fun stopProvideLocation() + + /** + * Changes the device address (e.g., BLE MAC, IP address) we are communicating with. + * + * @param address The new device identifier. + */ + fun setDeviceAddress(address: String) } diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioNotConnectedException.kt similarity index 58% rename from app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioNotConnectedException.kt index 31f28c799c..afeed6a67d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioNotConnectedException.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioNotConnectedException.kt @@ -14,17 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.model -import android.os.RemoteException - -open class RadioNotConnectedException(message: String = "Not connected to radio") : RemoteException(message) - -class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : - RadioNotConnectedException(message) - -class BLEException(message: String) : RadioNotConnectedException(message) - -class BLECharacteristicNotFoundException(message: String) : RadioNotConnectedException(message) - -class BLEConnectionClosing(message: String = "BLE connection is closing") : RadioNotConnectedException(message) +/** Exception thrown when an operation is attempted while not connected to a mesh radio. */ +open class RadioNotConnectedException(message: String = "Not connected to radio") : Exception(message) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt new file mode 100644 index 0000000000..1102441135 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt @@ -0,0 +1,38 @@ +/* + * 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.model + +import okio.ByteString +import org.meshtastic.proto.User + +data class Reaction( + val replyId: Int, + val user: User, + val emoji: String, + val timestamp: Long, + val snr: Float, + val rssi: Int, + val hopsAway: Int, + val packetId: Int = 0, + val status: MessageStatus = MessageStatus.UNKNOWN, + val routingError: Int = 0, + val relays: Int = 0, + val relayNode: Int? = null, + val to: String? = null, + val channel: Int = 0, + val sfppHash: ByteString? = null, +) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt similarity index 98% rename from core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt index bf5cddffca..cc1f5c95c8 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TAK.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.database.model +package org.meshtastic.core.model import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.resources.Res diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt similarity index 93% rename from core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt index 3ec87bcb04..a64822f447 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt @@ -14,9 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.service +package org.meshtastic.core.model.service -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.proto.SharedContact sealed class ServiceAction { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/TracerouteResponse.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/TracerouteResponse.kt new file mode 100644 index 0000000000..38cd9462ff --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/TracerouteResponse.kt @@ -0,0 +1,29 @@ +/* + * 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.model.service + +data class TracerouteResponse( + val message: String, + val destinationNodeNum: Int, + val requestId: Int, + val forwardRoute: List = emptyList(), + val returnRoute: List = emptyList(), + val logUuid: String? = null, +) { + val hasOverlay: Boolean + get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt index ff4d3c792b..c184d9fc17 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt @@ -83,7 +83,7 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null */ fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): CommonUri { val channelBytes = ChannelSet.ADAPTER.encode(this) - val enc = channelBytes.toByteString().base64Url() + val enc = channelBytes.toByteString().base64Url().replace("=", "") val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX val query = if (shouldAdd) "?add=true" else "" return CommonUri.parse("$p$query#$enc") diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index c7bf1e86dc..badef08337 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -29,10 +29,13 @@ configure { } dependencies { + api(projects.core.repository) implementation(projects.core.di) implementation(projects.core.model) + implementation(projects.core.proto) - implementation(libs.coil.network.core) + implementation(libs.org.eclipse.paho.client.mqttv3) + implementation(libs.okio) implementation(libs.coil.network.okhttp) implementation(libs.coil.svg) implementation(libs.kotlinx.serialization.json) @@ -40,6 +43,7 @@ dependencies { implementation(libs.ktor.client.okhttp) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.okhttp3.logging.interceptor) + implementation(libs.kermit) googleImplementation(libs.dd.sdk.android.okhttp) } diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt b/core/network/src/main/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt similarity index 96% rename from app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt rename to core/network/src/main/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt index 7ad3b4d699..960f4d8436 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/network/MQTTRepository.kt +++ b/core/network/src/main/kotlin/org/meshtastic/core/network/repository/MQTTRepository.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.repository.network +package org.meshtastic.core.network.repository import co.touchlab.kermit.Logger import kotlinx.coroutines.channels.awaitClose @@ -31,9 +31,9 @@ import org.eclipse.paho.client.mqttv3.MqttConnectOptions import org.eclipse.paho.client.mqttv3.MqttMessage import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence import org.meshtastic.core.common.util.ignoreException -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.util.subscribeList +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.proto.MqttClientProxyMessage import java.net.URI import java.security.SecureRandom @@ -99,6 +99,7 @@ constructor( } } + @Suppress("MagicNumber") val bufferOptions = DisconnectedBufferOptions().apply { isBufferEnabled = true @@ -163,6 +164,7 @@ constructor( Logger.i { "MQTT Subscribed to topic: $topic" } } + @Suppress("TooGenericExceptionCaught") fun publish(topic: String, data: ByteArray, retained: Boolean) { try { val token = mqttClient?.publish(topic, data, DEFAULT_QOS, retained) diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/TrustAllX509TrustManager.kt b/core/network/src/main/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt similarity index 90% rename from app/src/main/java/com/geeksville/mesh/repository/network/TrustAllX509TrustManager.kt rename to core/network/src/main/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt index d9c0425c69..720d2a5227 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/network/TrustAllX509TrustManager.kt +++ b/core/network/src/main/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.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,16 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - -package com.geeksville.mesh.repository.network +package org.meshtastic.core.network.repository import android.annotation.SuppressLint import java.security.cert.X509Certificate import javax.net.ssl.X509TrustManager @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") +@Suppress("EmptyFunctionBlock") class TrustAllX509TrustManager : X509TrustManager { override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() } diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index 84e01f5872..2274282720 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -25,6 +25,7 @@ plugins { configure { namespace = "org.meshtastic.core.prefs" } dependencies { + implementation(projects.core.repository) googleImplementation(libs.maps.compose) testImplementation(libs.junit) 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 fa3ef467c1..2e5285be83 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 @@ -109,6 +109,11 @@ interface PrefsModule { @Binds fun bindHomoglyphEncodingPrefs(homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl): HomoglyphPrefs + @Binds + fun bindSharedHomoglyphPrefs( + homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl, + ): org.meshtastic.core.repository.HomoglyphPrefs + @Binds fun bindCustomEmojiPrefs(customEmojiPrefsImpl: CustomEmojiPrefsImpl): CustomEmojiPrefs @Binds fun bindMapConsentPrefs(mapConsentPrefsImpl: MapConsentPrefsImpl): MapConsentPrefs diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt index d74962cfe4..b77b6fa970 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefs.kt @@ -24,11 +24,12 @@ import org.meshtastic.core.prefs.PrefDelegate import org.meshtastic.core.prefs.di.HomoglyphEncodingSharedPreferences import javax.inject.Inject import javax.inject.Singleton +import org.meshtastic.core.repository.HomoglyphPrefs as SharedHomoglyphPrefs -interface HomoglyphPrefs { +interface HomoglyphPrefs : SharedHomoglyphPrefs { /** Preference for whether homoglyph encoding is enabled by the user. */ - var homoglyphEncodingEnabled: Boolean + override var homoglyphEncodingEnabled: Boolean /** * Provides a [Flow] that emits the current state of [homoglyphEncodingEnabled] and subsequent changes. diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt b/core/repository/build.gradle.kts similarity index 57% rename from app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt rename to core/repository/build.gradle.kts index a9f1cf014f..778dde9475 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt +++ b/core/repository/build.gradle.kts @@ -14,20 +14,22 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.meshtastic.core.model.ConnectionState -import javax.inject.Inject -import javax.inject.Singleton +plugins { alias(libs.plugins.meshtastic.kmp.library) } -@Singleton -class ConnectionStateHandler @Inject constructor() { - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState = _connectionState.asStateFlow() +kotlin { + @Suppress("UnstableApiUsage") + android { androidResources.enable = false } - fun setState(state: ConnectionState) { - _connectionState.value = state + sourceSets { + commonMain.dependencies { + api(projects.core.model) + api(projects.core.proto) + implementation(projects.core.common) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kermit) + implementation(libs.androidx.paging.common) + } } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppWidgetUpdater.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppWidgetUpdater.kt new file mode 100644 index 0000000000..fc23047c03 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppWidgetUpdater.kt @@ -0,0 +1,23 @@ +/* + * 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.repository + +/** Interface for triggering updates to application widgets. */ +interface AppWidgetUpdater { + /** Triggers an update for all app widgets. */ + suspend fun updateAll() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt new file mode 100644 index 0000000000..e69310d68b --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -0,0 +1,89 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import okio.ByteString +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.NeighborInfo + +/** Interface for sending commands and packets to the mesh network. */ +@Suppress("TooManyFunctions") +interface CommandSender { + /** Starts the command sender with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Returns the current packet ID. */ + fun getCurrentPacketId(): Long + + /** Returns the cached local configuration. */ + fun getCachedLocalConfig(): LocalConfig + + /** Returns the cached channel set. */ + fun getCachedChannelSet(): ChannelSet + + /** Generates a new unique packet ID. */ + fun generatePacketId(): Int + + /** The latest neighbor info received from the connected radio. */ + var lastNeighborInfo: NeighborInfo? + + /** Start times of traceroute requests for duration calculation. */ + val tracerouteStartTimes: MutableMap + + /** Start times of neighbor info requests for duration calculation. */ + val neighborInfoStartTimes: MutableMap + + /** Sets the session passkey for admin messages. */ + fun setSessionPasskey(key: ByteString) + + /** Sends a data packet to the mesh. */ + fun sendData(p: DataPacket) + + /** Sends an admin message to a specific node. */ + fun sendAdmin( + destNum: Int, + requestId: Int = generatePacketId(), + wantResponse: Boolean = false, + initFn: () -> AdminMessage, + ) + + /** Sends our current position to the mesh. */ + fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) + + /** Requests the position of a specific node. */ + fun requestPosition(destNum: Int, currentPosition: Position) + + /** Sets a fixed position for a node. */ + fun setFixedPosition(destNum: Int, pos: Position) + + /** Requests user info from a specific node. */ + fun requestUserInfo(destNum: Int) + + /** Requests a traceroute to a specific node. */ + fun requestTraceroute(requestId: Int, destNum: Int) + + /** Requests telemetry from a specific node. */ + fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) + + /** Requests neighbor info from a specific node. */ + fun requestNeighborInfo(requestId: Int, destNum: Int) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt new file mode 100644 index 0000000000..675092382c --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt @@ -0,0 +1,34 @@ +/* + * 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.repository + +import kotlinx.coroutines.flow.StateFlow + +/** Interface for managing database instances and cache limits. */ +interface DatabaseManager { + /** Reactive stream of the current database cache limit. */ + val cacheLimit: StateFlow + + /** Returns the current database cache limit from storage. */ + fun getCurrentCacheLimit(): Int + + /** Sets the database cache limit. */ + fun setCacheLimit(limit: Int) + + /** Switches the active database to the one associated with the given [address]. */ + suspend fun switchActiveDatabase(address: String?) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceHardwareRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceHardwareRepository.kt new file mode 100644 index 0000000000..2c2a198cde --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceHardwareRepository.kt @@ -0,0 +1,35 @@ +/* + * 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.repository + +import org.meshtastic.core.model.DeviceHardware + +interface DeviceHardwareRepository { + /** + * Retrieves device hardware information by its model ID and optional target string. + * + * @param hwModel The hardware model identifier. + * @param target Optional PlatformIO target environment name to disambiguate multiple variants. + * @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely. + * @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure. + */ + suspend fun getDeviceHardwareByModel( + hwModel: Int, + target: String? = null, + forceRefresh: Boolean = false, + ): Result +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt new file mode 100644 index 0000000000..a362628c60 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt @@ -0,0 +1,25 @@ +/* + * 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.repository + +import org.meshtastic.proto.FromRadio + +/** Interface for dispatching non-packet [FromRadio] variants to their respective handlers. */ +interface FromRadioPacketHandler { + /** Processes a [FromRadio] message. */ + fun handleFromRadio(proto: FromRadio) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt new file mode 100644 index 0000000000..38d1f2ddc7 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt @@ -0,0 +1,46 @@ +/* + * 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.repository + +import org.meshtastic.proto.ModuleConfig + +/** Interface for managing store-and-forward history replay requests. */ +interface HistoryManager { + /** + * Requests a history replay from the radio. + * + * @param trigger A string identifying the trigger for the request (for logging). + * @param myNodeNum The local node number. + * @param storeForwardConfig The store-and-forward module configuration. + * @param transport The transport method being used (for logging). + */ + fun requestHistoryReplay( + trigger: String, + myNodeNum: Int?, + storeForwardConfig: ModuleConfig.StoreForwardConfig?, + transport: String, + ) + + /** + * Updates the last requested history marker. + * + * @param source A string identifying the source of the update (for logging). + * @param lastRequest The timestamp or sequence number of the last received history message. + * @param transport The transport method being used (for logging). + */ + fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt new file mode 100644 index 0000000000..4c497af0b5 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt @@ -0,0 +1,21 @@ +/* + * 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.repository + +interface HomoglyphPrefs { + val homoglyphEncodingEnabled: Boolean +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt new file mode 100644 index 0000000000..d55bbe2dd8 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt @@ -0,0 +1,123 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MeshUser +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.service.ServiceAction + +/** Interface for handling UI-triggered actions and administrative commands for the mesh. */ +@Suppress("TooManyFunctions") +interface MeshActionHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Processes a service action from the UI. */ + fun onServiceAction(action: ServiceAction) + + /** Sets the owner of the local node. */ + fun handleSetOwner(u: MeshUser, myNodeNum: Int) + + /** Sends a data packet through the mesh. */ + fun handleSend(p: DataPacket, myNodeNum: Int) + + /** Requests the position of a remote node. */ + fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) + + /** Removes a node from the database by its node number. */ + fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) + + /** Sets the owner of a remote node. */ + fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) + + /** Gets the owner of a remote node. */ + fun handleGetRemoteOwner(id: Int, destNum: Int) + + /** Sets the configuration of the local node. */ + fun handleSetConfig(payload: ByteArray, myNodeNum: Int) + + /** Sets the configuration of a remote node. */ + fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) + + /** Gets the configuration of a remote node. */ + fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) + + /** Sets the module configuration of a remote node. */ + fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) + + /** Gets the module configuration of a remote node. */ + fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) + + /** Sets the ringtone of a remote node. */ + fun handleSetRingtone(destNum: Int, ringtone: String) + + /** Gets the ringtone of a remote node. */ + fun handleGetRingtone(id: Int, destNum: Int) + + /** Sets canned messages on a remote node. */ + fun handleSetCannedMessages(destNum: Int, messages: String) + + /** Gets canned messages from a remote node. */ + fun handleGetCannedMessages(id: Int, destNum: Int) + + /** Sets a channel configuration on the local node. */ + fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) + + /** Sets a channel configuration on a remote node. */ + fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) + + /** Gets a channel configuration from a remote node. */ + fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) + + /** Requests neighbor information from a remote node. */ + fun handleRequestNeighborInfo(requestId: Int, destNum: Int) + + /** Begins editing settings on a remote node. */ + fun handleBeginEditSettings(destNum: Int) + + /** Commits settings edits on a remote node. */ + fun handleCommitEditSettings(destNum: Int) + + /** Reboots a remote node into DFU mode. */ + fun handleRebootToDfu(destNum: Int) + + /** Requests telemetry from a remote node. */ + fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) + + /** Requests a remote node to shut down. */ + fun handleRequestShutdown(requestId: Int, destNum: Int) + + /** Requests a remote node to reboot. */ + fun handleRequestReboot(requestId: Int, destNum: Int) + + /** Requests a remote node to reboot in OTA mode. */ + fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) + + /** Requests a factory reset on a remote node. */ + fun handleRequestFactoryReset(requestId: Int, destNum: Int) + + /** Requests a node database reset on a remote node. */ + fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) + + /** Gets the connection status of a remote node. */ + fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) + + /** Updates the last used device address. */ + fun handleUpdateLastAddress(deviceAddr: String?) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt new file mode 100644 index 0000000000..1f21df1ee9 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt @@ -0,0 +1,46 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.NodeInfo + +/** Interface for managing the configuration flow, including local node info and metadata. */ +interface MeshConfigFlowManager { + /** Starts the manager with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Handles received local node information. */ + fun handleMyInfo(myInfo: MyNodeInfo) + + /** Handles received local device metadata. */ + fun handleLocalMetadata(metadata: DeviceMetadata) + + /** Handles received node information. */ + fun handleNodeInfo(info: NodeInfo) + + /** Returns the number of nodes received in the current stage. */ + val newNodeCount: Int + + /** Handles the completion of a configuration stage. */ + fun handleConfigComplete(configCompleteId: Int) + + /** Triggers a request for the full device configuration. */ + fun triggerWantConfig() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt new file mode 100644 index 0000000000..aae9526f39 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt @@ -0,0 +1,46 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig + +/** Interface for handling device and module configuration updates. */ +interface MeshConfigHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Reactive local configuration. */ + val localConfig: StateFlow + + /** Reactive local module configuration. */ + val moduleConfig: StateFlow + + /** Handles a received device configuration. */ + fun handleDeviceConfig(config: Config) + + /** Handles a received module configuration. */ + fun handleModuleConfig(config: ModuleConfig) + + /** Handles a received channel configuration. */ + fun handleChannel(channel: Channel) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt new file mode 100644 index 0000000000..eae5bd9a0d --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -0,0 +1,44 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.proto.Telemetry + +/** Interface for managing the connection lifecycle and status with the mesh radio. */ +interface MeshConnectionManager { + /** Starts the connection manager with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Called when the radio configuration has been fully loaded. */ + fun onRadioConfigLoaded() + + /** Initiates the configuration synchronization stage. */ + fun startConfigOnly() + + /** Initiates the node information synchronization stage. */ + fun startNodeInfoOnly() + + /** Called when the node database is ready and fully populated. */ + fun onNodeDbReady() + + /** Updates the telemetry information for the local node. */ + fun updateTelemetry(t: Telemetry) + + /** Updates and returns the current status notification. */ + fun updateStatusNotification(telemetry: Telemetry? = null): Any +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt new file mode 100644 index 0000000000..2c7487cf99 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt @@ -0,0 +1,47 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import org.meshtastic.core.model.DataPacket +import org.meshtastic.proto.MeshPacket + +/** Interface for handling incoming mesh data packets and routing them to the appropriate handlers. */ +interface MeshDataHandler { + /** Starts the handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** + * Processes a received mesh packet. + * + * @param packet The received mesh packet. + * @param myNodeNum The local node number. + * @param logUuid Optional UUID for logging purposes. + * @param logInsertJob Optional job that tracks the insertion of the packet into the log. + */ + fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) + + /** + * Persists a data packet in the history and triggers notifications if necessary. + * + * @param dataPacket The data packet to remember. + * @param myNodeNum The local node number. + * @param updateNotification Whether to trigger a notification for this packet. + */ + fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt new file mode 100644 index 0000000000..e619550e66 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt @@ -0,0 +1,29 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.proto.Position + +/** Interface for managing the local node's location updates and reporting. */ +interface MeshLocationManager { + /** Starts location updates and reports them via the given function. */ + fun start(scope: CoroutineScope, sendPositionFn: (Position) -> Unit) + + /** Stops location updates. */ + fun stop() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt new file mode 100644 index 0000000000..1a3657d9e1 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt @@ -0,0 +1,35 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.proto.MeshPacket + +/** Interface for processing incoming radio messages and mesh packets. */ +interface MeshMessageProcessor { + /** Starts the processor with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Handles a raw message received from the radio. */ + fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) + + /** Handles a received mesh packet. */ + fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) + + /** Clears the buffer of early received packets. */ + fun clearEarlyPackets() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt new file mode 100644 index 0000000000..b4dd60a4d4 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt @@ -0,0 +1,46 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope + +/** Interface for the central router that orchestrates specialized mesh packet handlers. */ +interface MeshRouter { + /** Starts the router and its sub-components with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Access to the data handler. */ + val dataHandler: MeshDataHandler + + /** Access to the configuration handler. */ + val configHandler: MeshConfigHandler + + /** Access to the traceroute handler. */ + val tracerouteHandler: TracerouteHandler + + /** Access to the neighbor info handler. */ + val neighborInfoHandler: NeighborInfoHandler + + /** Access to the configuration flow manager. */ + val configFlowManager: MeshConfigFlowManager + + /** Access to the MQTT manager. */ + val mqttManager: MqttManager + + /** Access to the action handler. */ + val actionHandler: MeshActionHandler +} diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt similarity index 84% rename from core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt index 5af641d652..a4fefe2cdc 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt @@ -14,10 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.service +package org.meshtastic.core.repository -import android.app.Notification -import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.model.Node import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry @@ -29,7 +28,7 @@ interface MeshServiceNotifications { fun initChannels() - fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification + fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Any suspend fun updateMessageNotification( contactKey: String, @@ -59,15 +58,15 @@ interface MeshServiceNotifications { fun showAlertNotification(contactKey: String, name: String, alert: String) - fun showNewNodeSeenNotification(node: NodeEntity) + fun showNewNodeSeenNotification(node: Node) - fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) + fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) fun showClientNotification(clientNotification: ClientNotification) fun cancelMessageNotification(contactKey: String) - fun cancelLowBatteryNotification(node: NodeEntity) + fun cancelLowBatteryNotification(node: Node) fun clearClientNotification(notification: ClientNotification) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshWorkerManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshWorkerManager.kt new file mode 100644 index 0000000000..33ad246653 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshWorkerManager.kt @@ -0,0 +1,23 @@ +/* + * 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.repository + +/** Interface for managing background workers for mesh-related tasks. */ +interface MeshWorkerManager { + /** Enqueues a worker to send a specific packet. */ + fun enqueueSendMessage(packetId: Int) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageFilter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageFilter.kt new file mode 100644 index 0000000000..6b32e021df --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageFilter.kt @@ -0,0 +1,32 @@ +/* + * 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.repository + +/** Interface for filtering messages based on user-configured filter words. */ +interface MessageFilter { + /** + * Determines if a message should be filtered. + * + * @param message The message text to check. + * @param isFilteringDisabled Whether filtering is disabled for the current contact. + * @return true if the message should be filtered, false otherwise. + */ + fun shouldFilter(message: String, isFilteringDisabled: Boolean = false): Boolean + + /** Rebuilds the internal filter patterns. Should be called after filter words are updated. */ + fun rebuildPatterns() +} diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageQueue.kt similarity index 96% rename from core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageQueue.kt index 5142c89f9b..4097d7e375 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageQueue.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.domain +package org.meshtastic.core.repository /** * Interface for enqueuing background work for transmitting messages. This allows the domain layer to trigger durable diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt new file mode 100644 index 0000000000..cfda5a9d09 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt @@ -0,0 +1,32 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.proto.MqttClientProxyMessage + +/** Interface for managing MQTT proxy communication. */ +interface MqttManager { + /** Starts the MQTT manager with the given coroutine scope and settings. */ + fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) + + /** Stops the MQTT manager. */ + fun stop() + + /** Handles an MQTT proxy message from the radio. */ + fun handleMqttProxyMessage(message: MqttClientProxyMessage) +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt similarity index 58% rename from app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt index 2e4c605ea6..1dd95b5d93 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt @@ -14,19 +14,20 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.geeksville.mesh.service +package org.meshtastic.core.repository -import org.meshtastic.core.model.DataPacket +import kotlinx.coroutines.CoroutineScope import org.meshtastic.proto.MeshPacket -import javax.inject.Inject -import javax.inject.Singleton -import org.meshtastic.core.model.util.MeshDataMapper as CommonMeshDataMapper -@Singleton -class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManager) { - private val commonMapper = CommonMeshDataMapper(nodeManager) +/** Interface for handling neighbor info responses from the mesh. */ +interface NeighborInfoHandler { + /** Starts the neighbor info handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) - fun toNodeID(n: Int): String = nodeManager.toNodeID(n) - - fun toDataPacket(packet: MeshPacket): DataPacket? = commonMapper.toDataPacket(packet) + /** + * Processes a neighbor info packet. + * + * @param packet The received mesh packet. + */ + fun handleNeighborInfo(packet: MeshPacket) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt new file mode 100644 index 0000000000..15baf651e4 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -0,0 +1,104 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeInfo +import org.meshtastic.core.model.util.NodeIdLookup +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.Paxcount +import org.meshtastic.proto.StatusMessage +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition + +/** Interface for managing the in-memory node database and processing received node information. */ +@Suppress("TooManyFunctions") +interface NodeManager : NodeIdLookup { + /** Reactive map of all nodes by their number. */ + val nodeDBbyNodeNum: Map + + /** Reactive map of all nodes by their ID string. */ + val nodeDBbyID: Map + + /** Whether the node database is ready. */ + val isNodeDbReady: StateFlow + + /** Sets whether the node database is ready. */ + fun setNodeDbReady(ready: Boolean) + + /** Whether node database writes are allowed. */ + val allowNodeDbWrites: StateFlow + + /** Sets whether node database writes are allowed. */ + fun setAllowNodeDbWrites(allowed: Boolean) + + /** Starts the node manager with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** The local node number. */ + var myNodeNum: Int? + + /** Loads the cached node database from the repository. */ + fun loadCachedNodeDB() + + /** Clears the in-memory node database. */ + fun clear() + + /** Returns information about the local node. */ + fun getMyNodeInfo(): MyNodeInfo? + + /** Returns the local node ID. */ + fun getMyId(): String + + /** Returns a list of all known nodes. */ + fun getNodes(): List + + /** Processes a received user packet. */ + fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) + + /** Processes a received position packet. */ + fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) + + /** Processes a received telemetry packet. */ + fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) + + /** Processes a received paxcounter packet. */ + fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) + + /** Processes a received node status message. */ + fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) + + /** Updates the status string for a node. */ + fun updateNodeStatus(nodeNum: Int, status: String?) + + /** Updates a node using a transformation function. */ + fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) + + /** Removes a node from the in-memory database by its number. */ + fun removeByNodenum(nodeNum: Int) + + /** Installs node information from a ProtoNodeInfo object. */ + fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) + + /** Inserts hardware metadata for a node. */ + fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt new file mode 100644 index 0000000000..8c35c51084 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt @@ -0,0 +1,177 @@ +/* + * 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.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.User + +/** + * Repository interface for managing node-related data. + * + * This component provides access to the mesh's node database, local device information, and mesh-wide statistics. It + * supports reactive queries for node lists, counts, and filtered/sorted views. + * + * This interface is shared across platforms via Kotlin Multiplatform (KMP). + */ +@Suppress("TooManyFunctions") +interface NodeRepository { + /** Reactive flow of hardware info about our local radio device. */ + val myNodeInfo: StateFlow + + /** + * Reactive flow of information about the locally connected node as seen by the mesh. + * + * This includes its position, telemetry, and user information as reflected in the mesh's node DB. + */ + val ourNodeInfo: StateFlow + + /** The unique userId (hex string, e.g., "!1234abcd") of our local node. */ + val myId: StateFlow + + /** Reactive flow of the latest local stats telemetry received from the radio. */ + val localStats: StateFlow + + /** A reactive map of all known nodes in the mesh, keyed by their 32-bit node number. */ + val nodeDBbyNum: StateFlow> + + /** Flow emitting the count of nodes currently considered "online" (heard from recently). */ + val onlineNodeCount: Flow + + /** Flow emitting the total number of nodes in the database. */ + val totalNodeCount: Flow + + /** + * Updates the cached local stats telemetry. + * + * @param stats The new [LocalStats]. + */ + fun updateLocalStats(stats: LocalStats) + + /** + * Returns the node number used for log queries. + * + * Maps the local node's number to a constant (e.g., 0) to distinguish it from remote logs. + */ + fun effectiveLogNodeId(nodeNum: Int): Flow + + /** + * Returns the [Node] associated with a given [userId]. + * + * @param userId The hex string identifier. + * @return The found [Node] or a fallback object. + */ + fun getNode(userId: String): Node + + /** + * Returns the [User] info for a given [nodeNum]. + * + * @param nodeNum The 32-bit node number. + * @return The associated [User] proto. + */ + fun getUser(nodeNum: Int): User + + /** + * Returns the [User] info for a given [userId]. + * + * @param userId The hex string identifier. + * @return The associated [User] proto. + */ + fun getUser(userId: String): User + + /** + * Returns a reactive flow of nodes filtered and sorted according to the parameters. + * + * @param sort The [NodeSortOption] to apply. + * @param filter A search string for filtering by name or ID. + * @param includeUnknown Whether to include nodes with unset hardware models. + * @param onlyOnline Whether to include only nodes currently considered online. + * @param onlyDirect Whether to include only nodes heard directly (0 hops away). + */ + fun getNodes( + sort: NodeSortOption = NodeSortOption.LAST_HEARD, + filter: String = "", + includeUnknown: Boolean = true, + onlyOnline: Boolean = false, + onlyDirect: Boolean = false, + ): Flow> + + /** Returns all nodes that haven't been heard from since the given timestamp. */ + suspend fun getNodesOlderThan(lastHeard: Int): List + + /** Returns all nodes with unknown hardware models. */ + suspend fun getUnknownNodes(): List + + /** + * Deletes all nodes from the database. + * + * @param preserveFavorites If true, nodes marked as favorite will not be deleted. + */ + suspend fun clearNodeDB(preserveFavorites: Boolean = false) + + /** Clears the local node's connection info from the cache. */ + suspend fun clearMyNodeInfo() + + /** + * Deletes a specific node by its node number. + * + * @param num The node number to delete. + */ + suspend fun deleteNode(num: Int) + + /** + * Deletes multiple nodes by their node numbers. + * + * @param nodeNums The list of node numbers to delete. + */ + suspend fun deleteNodes(nodeNums: List) + + /** + * Updates the personal notes for a node. + * + * @param num The node number. + * @param notes The human-readable notes to persist. + */ + suspend fun setNodeNotes(num: Int, notes: String) + + /** + * Upserts a [Node] into the persistent database. + * + * @param node The [Node] model to save. + */ + suspend fun upsert(node: Node) + + /** + * Installs initial configuration data (local info and remote nodes) into the database. + * + * Used during the initial connection handshake. + */ + suspend fun installConfig(mi: MyNodeInfo, nodes: List) + + /** + * Persists hardware metadata for a node. + * + * @param nodeNum The node number. + * @param metadata The [DeviceMetadata] to save. + */ + suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt new file mode 100644 index 0000000000..5b6d785284 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt @@ -0,0 +1,43 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.ToRadio + +/** Interface for handling the transmission of packets to the radio and managing the packet queue. */ +interface PacketHandler { + /** Starts the packet handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** Sends a command/packet directly to the radio. */ + fun sendToRadio(p: ToRadio) + + /** Adds a mesh packet to the queue for sending. */ + fun sendToRadio(packet: MeshPacket) + + /** Processes queue status updates from the radio. */ + fun handleQueueStatus(queueStatus: QueueStatus) + + /** Removes a pending response for a request. */ + fun removeResponse(dataRequestId: Int, complete: Boolean) + + /** Stops the packet queue. */ + fun stopPacketQueue() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt new file mode 100644 index 0000000000..c43d559c45 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -0,0 +1,213 @@ +/* + * 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.repository + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.model.ContactSettings +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Message +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction +import org.meshtastic.proto.ChannelSettings + +/** + * Repository interface for managing mesh packets and message history. + * + * This component provides methods for persisting received packets, querying message history, tracking unread counts, + * and managing contact-specific settings. It supports both reactive (Flow) and one-shot (suspend) queries. + */ +@Suppress("TooManyFunctions") +interface PacketRepository { + /** Reactive flow of all persisted waypoints (GPS locations). */ + fun getWaypoints(): Flow> + + /** Reactive flow of all conversation contacts, keyed by their contact identifier. */ + fun getContacts(): Flow> + + /** Reactive paged flow of conversation contacts. */ + fun getContactsPaged(): Flow> + + /** Returns the total number of messages in a conversation. */ + suspend fun getMessageCount(contact: String): Int + + /** Returns the count of unread messages in a conversation. */ + suspend fun getUnreadCount(contact: String): Int + + /** Reactive flow of the UUID of the first unread message in a conversation. */ + fun getFirstUnreadMessageUuid(contact: String): Flow + + /** Reactive flow indicating whether a conversation has any unread messages. */ + fun hasUnreadMessages(contact: String): Flow + + /** Reactive flow of the total unread message count across all conversations. */ + fun getUnreadCountTotal(): Flow + + /** Clears the unread status for messages in a conversation up to the given timestamp. */ + suspend fun clearUnreadCount(contact: String, timestamp: Long) + + /** Updates the identifier of the last read message in a conversation. */ + suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) + + /** Returns all packets currently queued for transmission. */ + suspend fun getQueuedPackets(): List? + + /** + * Persists a packet in the database. + * + * @param myNodeNum The local node number at the time of receipt. + * @param contactKey The identifier of the associated conversation. + * @param packet The [DataPacket] to save. + * @param receivedTime The timestamp (ms) the packet was received. + * @param read Whether the packet should be marked as already read. + * @param filtered Whether the packet was filtered by message rules. + */ + suspend fun savePacket( + myNodeNum: Int, + contactKey: String, + packet: DataPacket, + receivedTime: Long, + read: Boolean = true, + filtered: Boolean = false, + ) + + /** + * Returns a reactive flow of messages for a conversation. + * + * @param contact The conversation identifier. + * @param limit Optional maximum number of messages to return. + * @param includeFiltered Whether to include messages that were marked as filtered. + * @param getNode Callback to fetch node info for message sender attribution. + */ + suspend fun getMessagesFrom( + contact: String, + limit: Int? = null, + includeFiltered: Boolean = true, + getNode: suspend (String?) -> Node, + ): Flow> + + /** Returns a paged flow of messages for a conversation. */ + fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow> + + /** Returns a paged flow of messages for a conversation, with filtering options. */ + fun getMessagesFromPaged( + contactKey: String, + includeFiltered: Boolean, + getNode: suspend (String?) -> Node, + ): Flow> + + /** Updates the transmission status of a packet. */ + suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) + + /** Updates the identifier of a persisted packet. */ + suspend fun updateMessageId(d: DataPacket, id: Int) + + /** Deletes messages by their database UUIDs. */ + suspend fun deleteMessages(uuidList: List) + + /** Deletes all messages and settings for the given contacts. */ + suspend fun deleteContacts(contactList: List) + + /** Deletes a waypoint by its ID. */ + suspend fun deleteWaypoint(id: Int) + + /** Reactive flow of all contact settings (e.g., mute status). */ + fun getContactSettings(): Flow> + + /** Returns the settings for a specific contact. */ + suspend fun getContactSettings(contact: String): ContactSettings + + /** Mutes the given contacts until the specified timestamp. */ + suspend fun setMuteUntil(contacts: List, until: Long) + + /** Reactive flow of the number of filtered messages for a contact. */ + fun getFilteredCountFlow(contactKey: String): Flow + + /** Returns the total count of filtered messages for a contact. */ + suspend fun getFilteredCount(contactKey: String): Int + + /** Disables or enables message filtering for a specific contact. */ + suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) + + /** Clears all packet and message history from the database. */ + suspend fun clearPacketDB() + + /** Migrates channel-specific message history when encryption keys change. */ + suspend fun migrateChannelsByPSK(oldSettings: List, newSettings: List) + + /** Marks all messages from a specific sender as filtered or unfiltered. */ + suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) + + /** Returns a packet by its mesh-layer packet ID. */ + suspend fun getPacketByPacketId(packetId: Int): DataPacket? + + /** Returns a packet by its internal database ID. */ + suspend fun getPacketById(id: Int): DataPacket? + + /** Inserts a packet into the database. */ + suspend fun insert( + packet: DataPacket, + myNodeNum: Int, + contactKey: String, + receivedTime: Long, + read: Boolean = true, + filtered: Boolean = false, + ) + + /** Updates an existing packet in the database. */ + suspend fun update(packet: DataPacket) + + /** Persists a message reaction (emoji). */ + suspend fun insertReaction(reaction: Reaction, myNodeNum: Int) + + /** Updates an existing reaction. */ + suspend fun updateReaction(reaction: Reaction) + + /** Returns a reaction associated with a specific packet ID. */ + suspend fun getReactionByPacketId(packetId: Int): Reaction? + + /** Finds all packets matching a specific packet ID. */ + suspend fun findPacketsWithId(packetId: Int): List + + /** Finds all reactions associated with a specific packet ID. */ + suspend fun findReactionsWithId(packetId: Int): List + + /** + * Updates the Store-and-Forward PlusPlus (SFPP) status for packets. + * + * @param packetId The packet ID. + * @param from The sender node number. + * @param to The recipient node number. + * @param hash The SFPP commit hash. + * @param status The new SFPP-specific message status. + * @param rxTime The receipt time from the mesh. + * @param myNodeNum The local node number. + */ + suspend fun updateSFPPStatus( + packetId: Int, + from: Int, + to: Int, + hash: ByteArray, + status: MessageStatus, + rxTime: Long, + myNodeNum: Int?, + ) + + /** Updates the SFPP status of packets matching the given commit hash. */ + suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt new file mode 100644 index 0000000000..48053ab80a --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt @@ -0,0 +1,62 @@ +/* + * 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.repository + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig + +interface RadioConfigRepository { + /** Flow representing the [ChannelSet] data store. */ + val channelSetFlow: Flow + + /** Clears the [ChannelSet] data in the data store. */ + suspend fun clearChannelSet() + + /** Replaces the [ChannelSettings] list with a new [settingsList]. */ + suspend fun replaceAllSettings(settingsList: List) + + /** Updates the [ChannelSettings] list with the provided channel. */ + suspend fun updateChannelSettings(channel: Channel) + + /** Flow representing the [LocalConfig] data store. */ + val localConfigFlow: Flow + + /** Clears the [LocalConfig] data in the data store. */ + suspend fun clearLocalConfig() + + /** Updates [LocalConfig] from each [Config] oneOf. */ + suspend fun setLocalConfig(config: Config) + + /** Flow representing the [LocalModuleConfig] data store. */ + val moduleConfigFlow: Flow + + /** Clears the [LocalModuleConfig] data in the data store. */ + suspend fun clearLocalModuleConfig() + + /** Updates [LocalModuleConfig] from each [ModuleConfig] oneOf. */ + suspend fun setLocalModuleConfig(config: ModuleConfig) + + /** Flow representing the combined [DeviceProfile] protobuf. */ + val deviceProfileFlow: Flow +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt new file mode 100644 index 0000000000..787863341d --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -0,0 +1,72 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.model.MeshActivity + +/** Interface for the low-level radio interface that handles raw byte communication. */ +interface RadioInterfaceService { + /** Reactive connection state of the radio. */ + val connectionState: StateFlow + + /** Flow of the current device address. */ + val currentDeviceAddressFlow: StateFlow + + /** Whether we are currently using a mock interface. */ + fun isMockInterface(): Boolean + + /** Flow of raw data received from the radio. */ + val receivedData: SharedFlow + + /** Flow of radio activity events. */ + val meshActivity: SharedFlow + + /** Sends a raw byte array to the radio. */ + fun sendToRadio(bytes: ByteArray) + + /** Initiates the connection to the radio. */ + fun connect() + + /** Returns the current device address. */ + fun getDeviceAddress(): String? + + /** Sets the device address to connect to. */ + fun setDeviceAddress(deviceAddr: String?): Boolean + + /** Constructs a full radio address for the specific interface type. */ + fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String + + /** Called by an interface when it has successfully connected. */ + fun onConnect() + + /** Called by an interface when it has disconnected. */ + fun onDisconnect(isPermanent: Boolean) + + /** Called by an interface when it has disconnected with an error. */ + fun onDisconnect(error: Any) + + /** Called by an interface when it has received raw data from the radio. */ + fun handleFromRadio(bytes: ByteArray) + + /** The scope in which interface-related coroutines should run. */ + val serviceScope: CoroutineScope +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt new file mode 100644 index 0000000000..fe3bf75381 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt @@ -0,0 +1,39 @@ +/* + * 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.repository + +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node + +/** Interface for broadcasting service-level events to the application. */ +interface ServiceBroadcasts { + /** Subscribes a receiver to mesh broadcasts. */ + fun subscribeReceiver(receiverName: String, packageName: String) + + /** Broadcasts received data to the application. */ + fun broadcastReceivedData(dataPacket: DataPacket) + + /** Broadcasts that the radio connection state has changed. */ + fun broadcastConnection() + + /** Broadcasts that node information has changed. */ + fun broadcastNodeChange(node: Node) + + /** Broadcasts that the status of a message has changed. */ + fun broadcastMessageStatus(packetId: Int, status: MessageStatus) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt new file mode 100644 index 0000000000..4a8af11439 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt @@ -0,0 +1,147 @@ +/* + * 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.repository + +import co.touchlab.kermit.Severity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.model.service.TracerouteResponse +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.MeshPacket + +/** + * Interface for managing background service state, connection status, and mesh events. + * + * This repository acts as the primary data bridge between the long-running mesh service and the UI/Feature layers. It + * maintains reactive flows for connection status, error messages, and incoming mesh traffic. + */ +@Suppress("TooManyFunctions") +interface ServiceRepository { + /** Reactive flow of the current connection state. */ + val connectionState: StateFlow + + /** + * Updates the current connection state. + * + * @param connectionState The new [ConnectionState]. + */ + fun setConnectionState(connectionState: ConnectionState) + + /** + * Reactive flow of high-level client notifications. + * + * These represent events from the mesh client that may require UI feedback. + */ + val clientNotification: StateFlow + + /** + * Sets the current client notification. + * + * @param notification The [ClientNotification] to display or act upon. + */ + fun setClientNotification(notification: ClientNotification?) + + /** Clears the current client notification. */ + fun clearClientNotification() + + /** + * Reactive flow of human-readable error messages. + * + * These are typically shown as snackbars or dialogs in the UI. + */ + val errorMessage: StateFlow + + /** + * Sets an error message to be displayed. + * + * @param text The error message text. + * @param severity The [Severity] level of the error. + */ + fun setErrorMessage(text: String, severity: Severity = Severity.Error) + + /** Clears the current error message. */ + fun clearErrorMessage() + + /** + * Reactive flow of connection progress messages. + * + * Used during the handshake and config loading phase to provide status updates to the user. + */ + val connectionProgress: StateFlow + + /** + * Sets the connection progress message. + * + * @param text The progress description (e.g., "Downloading Node DB..."). + */ + fun setConnectionProgress(text: String) + + /** + * Flow of all raw [MeshPacket] objects received from the mesh. + * + * Subscribing to this flow allows components to react to any incoming traffic. + */ + val meshPacketFlow: SharedFlow + + /** + * Emits a mesh packet into the flow. + * + * Called by the packet processor when new data arrives from the radio. + * + * @param packet The received [MeshPacket]. + */ + suspend fun emitMeshPacket(packet: MeshPacket) + + /** Reactive flow of the most recent traceroute result. */ + val tracerouteResponse: StateFlow + + /** + * Sets the traceroute response. + * + * @param value The [TracerouteResponse] result. + */ + fun setTracerouteResponse(value: TracerouteResponse?) + + /** Clears the current traceroute response. */ + fun clearTracerouteResponse() + + /** Reactive flow of the most recent neighbor info response (formatted string). */ + val neighborInfoResponse: StateFlow + + /** + * Sets the neighbor info response. + * + * @param value The human-readable neighbor info string. + */ + fun setNeighborInfoResponse(value: String?) + + /** Clears the current neighbor info response. */ + fun clearNeighborInfoResponse() + + /** Flow of service actions requested by the UI (e.g., "Favorite Node", "Mute Node"). */ + val serviceAction: Flow + + /** + * Dispatches a service action to be handled by the background service. + * + * @param action The [ServiceAction] to perform. + */ + suspend fun onServiceAction(action: ServiceAction) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt new file mode 100644 index 0000000000..bff5f03a0e --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt @@ -0,0 +1,36 @@ +/* + * 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.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import org.meshtastic.proto.MeshPacket + +/** Interface for handling traceroute responses from the mesh. */ +interface TracerouteHandler { + /** Starts the traceroute handler with the given coroutine scope. */ + fun start(scope: CoroutineScope) + + /** + * Processes a traceroute packet. + * + * @param packet The received mesh packet. + * @param logUuid Optional UUID for the associated log entry. + * @param logInsertJob Optional job for the log entry insertion, to ensure ordering. + */ + fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) +} diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt similarity index 73% rename from core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt index ca2cf3f775..6aff094731 100644 --- a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt @@ -14,34 +14,37 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.domain.usecase +package org.meshtastic.core.repository.usecase import co.touchlab.kermit.Logger import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.domain.MessageQueue import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController -import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs +import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MessageQueue +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository import org.meshtastic.proto.Config -import javax.inject.Inject -import kotlin.math.abs import kotlin.random.Random /** - * Use case for sending a message. This component handles message transformation, persistence, and enqueuing for durable - * delivery. + * Use case for sending a message over the mesh network. + * + * This component orchestrates the process of: + * 1. Resolving the destination and sender information. + * 2. Handling implicit actions for direct messages (e.g., sharing contacts, favoriting). + * 3. Applying message transformations (e.g., homoglyph encoding). + * 4. Persisting the outgoing message in the local history. + * 5. Enqueuing the message for durable delivery via the platform's message queue. + * + * This implementation is platform-agnostic and relies on injected repositories and controllers. */ @Suppress("TooGenericExceptionCaught") -class SendMessageUseCase -@Inject -constructor( +class SendMessageUseCase( private val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, private val radioController: RadioController, @@ -49,6 +52,13 @@ constructor( private val messageQueue: MessageQueue, ) { + /** + * Executes the send message workflow. + * + * @param text The plain text message to send. + * @param contactKey The identifier of the target contact or channel (e.g., "0!ffffffff" for broadcast). + * @param replyId Optional ID of a message being replied to. + */ @Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod") suspend operator fun invoke( text: String, @@ -85,7 +95,7 @@ constructor( text } - val packetId = abs(Random.nextInt()) + val packetId = Random.nextInt(1, Int.MAX_VALUE) val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { @@ -94,25 +104,14 @@ constructor( status = MessageStatus.QUEUED } - val packetToSave = - Packet( - uuid = 0L, - myNodeNum = ourNode?.num ?: 0, - packetId = packetId, - port_num = packet.dataType, - contact_key = contactKey, - received_time = nowMillis, - read = true, - data = packet, - snr = packet.snr, - rssi = packet.rssi, - hopsAway = packet.hopsAway, - filtered = false, - ) - try { // Write to the DB to immediately reflect the queued state on the UI - packetRepository.insert(packetToSave) + packetRepository.savePacket( + myNodeNum = ourNode?.num ?: 0, + contactKey = contactKey, + packet = packet, + receivedTime = nowMillis, + ) // Enqueue for durable transmission via the platform-specific queue messageQueue.enqueue(packetId) diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt index ae582faa34..052ebe321c 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -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) + } } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt similarity index 68% rename from core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt rename to core/service/src/main/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt index 858e1695bf..07a53aa16a 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt @@ -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 = emptyList(), - val returnRoute: List = 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 = MutableStateFlow(ConnectionState.Disconnected) - open val connectionState: StateFlow + override val connectionState: StateFlow get() = _connectionState - fun setConnectionState(connectionState: ConnectionState) { + override fun setConnectionState(connectionState: ConnectionState) { _connectionState.value = connectionState } private val _clientNotification = MutableStateFlow(null) - val clientNotification: StateFlow + override val clientNotification: StateFlow 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(null) - val errorMessage: StateFlow + override val errorMessage: StateFlow 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(null) - val connectionProgress: StateFlow + override val connectionProgress: StateFlow get() = _connectionProgress - fun setConnectionProgress(text: String) { + override fun setConnectionProgress(text: String) { if (connectionState.value != ConnectionState.Connected) { _connectionProgress.value = text } } private val _meshPacketFlow = MutableSharedFlow(extraBufferCapacity = 64) - val meshPacketFlow: SharedFlow + override val meshPacketFlow: SharedFlow get() = _meshPacketFlow - suspend fun emitMeshPacket(packet: MeshPacket) { + override suspend fun emitMeshPacket(packet: MeshPacket) { _meshPacketFlow.emit(packet) } private val _tracerouteResponse = MutableStateFlow(null) - val tracerouteResponse: StateFlow + override val tracerouteResponse: StateFlow 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(null) - val neighborInfoResponse: StateFlow + override val neighborInfoResponse: StateFlow 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() - val serviceAction = _serviceAction.receiveAsFlow() + override val serviceAction: Flow = _serviceAction.receiveAsFlow() - suspend fun onServiceAction(action: ServiceAction) { + override suspend fun onServiceAction(action: ServiceAction) { _serviceAction.send(action) } } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt index 0df2b76e51..38bb9feffa 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt @@ -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 } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt index 4fba06a9d2..0ea0d30477 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt @@ -28,7 +28,7 @@ import com.google.zxing.WriterException import com.google.zxing.common.BitMatrix import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.toPlatformUri -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.getSharedContactUrl import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.share_contact diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt index afb0539af3..1d685aafe3 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MainAppBar.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ic_meshtastic import org.meshtastic.core.resources.navigate_back diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt index b1df96dcc2..c5c040bcd2 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeChip.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.Paxcount import org.meshtastic.proto.User diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index 50878e6f8d..e8c964743a 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.signal_quality import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt index 4fd2cb94d7..179e168bca 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/NodePreviewParameterProvider.kt @@ -18,8 +18,8 @@ package org.meshtastic.core.ui.component.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import okio.ByteString.Companion.toByteString -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime +import org.meshtastic.core.model.Node import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt index 0941b68afc..667a97ff20 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/preview/PreviewUtils.kt @@ -19,7 +19,7 @@ package org.meshtastic.core.ui.component.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.Paxcount import org.meshtastic.proto.User diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index f8f7e07aa1..cf3ab34048 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -16,14 +16,12 @@ */ package org.meshtastic.core.ui.qr -import android.os.RemoteException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Channel @@ -37,7 +35,7 @@ class ScannedQrCodeViewModel @Inject constructor( private val radioConfigRepository: RadioConfigRepository, - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, ) : ViewModel() { val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet()) @@ -56,19 +54,11 @@ constructor( } private fun setChannel(channel: Channel) { - try { - serviceRepository.meshService?.setChannel(Channel.ADAPTER.encode(channel)) - } catch (ex: RemoteException) { - Logger.e(ex) { "Set channel error" } - } + viewModelScope.launch { radioController.setLocalChannel(channel) } } // Set the radio config (also updates our saved copy in preferences) private fun setConfig(config: Config) { - try { - serviceRepository.meshService?.setConfig(Config.ADAPTER.encode(config)) - } catch (ex: RemoteException) { - Logger.e(ex) { "Set config error" } - } + viewModelScope.launch { radioController.setLocalConfig(config) } } } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt index 2c467cb662..d0feb933d9 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt @@ -21,10 +21,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.service.ServiceAction -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.SharedContact import javax.inject.Inject diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 84a5e9538a..92d70fe4e9 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -37,19 +37,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString -import org.meshtastic.core.data.repository.DeviceHardwareRepository import org.meshtastic.core.data.repository.FirmwareReleaseRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.datastore.BootloaderWarningDataSource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.prefs.radio.isBle import org.meshtastic.core.prefs.radio.isSerial import org.meshtastic.core.prefs.radio.isTcp +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.firmware_update_battery_low import org.meshtastic.core.resources.firmware_update_copying @@ -72,7 +73,6 @@ import org.meshtastic.core.resources.firmware_update_unknown_hardware import org.meshtastic.core.resources.firmware_update_updating import org.meshtastic.core.resources.firmware_update_validating import org.meshtastic.core.resources.unknown -import org.meshtastic.core.service.ServiceRepository import java.io.File import javax.inject.Inject @@ -95,7 +95,7 @@ constructor( private val firmwareReleaseRepository: FirmwareReleaseRepository, private val deviceHardwareRepository: DeviceHardwareRepository, private val nodeRepository: NodeRepository, - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, private val radioPrefs: RadioPrefs, private val bootloaderWarningDataSource: BootloaderWarningDataSource, private val firmwareUpdateManager: FirmwareUpdateManager, @@ -106,6 +106,8 @@ constructor( private val _state = MutableStateFlow(FirmwareUpdateState.Idle) val state: StateFlow = _state.asStateFlow() + val connectionState = radioController.connectionState + private val _selectedReleaseType = MutableStateFlow(FirmwareReleaseType.STABLE) val selectedReleaseType: StateFlow = _selectedReleaseType.asStateFlow() @@ -429,14 +431,14 @@ constructor( // Trigger a fresh connection attempt by MeshService address?.let { currentAddr -> Logger.i { "Post-update: Requesting MeshService to reconnect to $currentAddr" } - serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX$currentAddr") + radioController.setDeviceAddress("$DFU_RECONNECT_PREFIX$currentAddr") } // Wait for device to reconnect and settle val result = withTimeoutOrNull(VERIFY_TIMEOUT) { // Wait for both Connected state and node info to be present - serviceRepository.connectionState.first { it is ConnectionState.Connected } + connectionState.first { it is ConnectionState.Connected } nodeRepository.ourNodeInfo.filterNotNull().first() delay(VERIFY_DELAY) // Extra buffer for initial config sync true @@ -462,7 +464,7 @@ constructor( return !isBatteryLow } - private suspend fun getDeviceHardware(ourNode: MyNodeEntity): DeviceHardware? { + private suspend fun getDeviceHardware(ourNode: MyNodeInfo): DeviceHardware? { val nodeInfo = nodeRepository.ourNodeInfo.value val hwModelInt = nodeInfo?.user?.hw_model?.value val target = ourNode.pioEnv diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt index d104d18d4d..72cd5ed5fe 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt @@ -33,12 +33,12 @@ import no.nordicsemi.android.dfu.DfuServiceListenerHelper import org.jetbrains.compose.resources.getString import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.firmware_update_downloading_percent import org.meshtastic.core.resources.firmware_update_nordic_failed import org.meshtastic.core.resources.firmware_update_not_found_in_release import org.meshtastic.core.resources.firmware_update_starting_service -import org.meshtastic.core.service.ServiceRepository import java.io.File import javax.inject.Inject @@ -53,7 +53,7 @@ class NordicDfuHandler constructor( private val firmwareRetriever: FirmwareRetriever, @ApplicationContext private val context: Context, - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, ) : FirmwareUpdateHandler { override suspend fun startUpdate( @@ -113,7 +113,7 @@ constructor( updateState(FirmwareUpdateState.Processing(ProgressState(startingMsg))) // n = Nordic (Legacy prefix handling in mesh service) - serviceRepository.meshService?.setDeviceAddress("n") + radioController.setDeviceAddress("n") DfuServiceInitiator(address) .setDeviceName(deviceHardware.displayName) diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt index 4e7075c217..19534440c8 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt @@ -23,12 +23,13 @@ import kotlinx.coroutines.delay import org.jetbrains.compose.resources.getString import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.firmware_update_downloading_percent import org.meshtastic.core.resources.firmware_update_rebooting import org.meshtastic.core.resources.firmware_update_retrieval_failed import org.meshtastic.core.resources.firmware_update_usb_failed -import org.meshtastic.core.service.ServiceRepository import java.io.File import javax.inject.Inject @@ -40,7 +41,8 @@ class UsbUpdateHandler @Inject constructor( private val firmwareRetriever: FirmwareRetriever, - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, + private val nodeRepository: NodeRepository, ) : FirmwareUpdateHandler { override suspend fun startUpdate( @@ -62,8 +64,8 @@ constructor( if (firmwareUri != null) { updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg))) - val myNodeNum = serviceRepository.meshService?.getMyNodeInfo()?.myNodeNum ?: 0 - serviceRepository.meshService?.rebootToDfu(myNodeNum) + val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0 + radioController.rebootToDfu(myNodeNum) delay(REBOOT_DELAY) updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri)) @@ -85,8 +87,8 @@ constructor( null } else { updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg))) - val myNodeNum = serviceRepository.meshService?.getMyNodeInfo()?.myNodeNum ?: 0 - serviceRepository.meshService?.rebootToDfu(myNodeNum) + val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0 + radioController.rebootToDfu(myNodeNum) delay(REBOOT_DELAY) updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name)) diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 06bffbb496..20c4d44030 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -21,14 +21,18 @@ import android.net.Uri import co.touchlab.kermit.Logger import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import no.nordicsemi.kotlin.ble.client.android.CentralManager import org.jetbrains.compose.resources.getString import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.firmware_update_connecting_attempt import org.meshtastic.core.resources.firmware_update_downloading_percent @@ -40,7 +44,6 @@ import org.meshtastic.core.resources.firmware_update_retrieval_failed import org.meshtastic.core.resources.firmware_update_starting_ota import org.meshtastic.core.resources.firmware_update_uploading import org.meshtastic.core.resources.firmware_update_waiting_reboot -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.feature.firmware.FirmwareRetriever import org.meshtastic.feature.firmware.FirmwareUpdateHandler import org.meshtastic.feature.firmware.FirmwareUpdateState @@ -68,7 +71,8 @@ class Esp32OtaUpdateHandler @Inject constructor( private val firmwareRetriever: FirmwareRetriever, - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, + private val nodeRepository: NodeRepository, private val centralManager: CentralManager, @ApplicationContext private val context: Context, ) : FirmwareUpdateHandler { @@ -201,13 +205,11 @@ constructor( } private fun triggerRebootOta(mode: Int, hash: ByteArray?) { - val service = serviceRepository.meshService ?: return - try { - val myInfo = service.getMyNodeInfo() ?: return - Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" } - service.requestRebootOta(service.getPacketId(), myInfo.myNodeNum, mode, hash) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "ESP32 OTA: Failed to trigger reboot OTA" } + val myInfo = nodeRepository.myNodeInfo.value ?: return + val myNodeNum = myInfo.myNodeNum + Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" } + CoroutineScope(Dispatchers.IO).launch { + radioController.requestRebootOta(radioController.getPacketId(), myNodeNum, mode, hash) } } @@ -216,12 +218,8 @@ constructor( * interface) cleanly disconnects without reconnection attempts. */ private fun disconnectMeshService() { - try { - Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" } - serviceRepository.meshService?.setDeviceAddress("n") - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "ESP32 OTA: Error disconnecting mesh service" } - } + Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" } + radioController.setDeviceAddress("n") } private suspend fun obtainFirmwareFile( diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt index 981067a03a..62f586a53a 100644 --- a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt @@ -33,7 +33,8 @@ import org.junit.Before import org.junit.Test import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.feature.firmware.FirmwareRetriever import org.meshtastic.feature.firmware.FirmwareUpdateState import java.io.IOException @@ -42,12 +43,14 @@ import java.io.IOException class Esp32OtaUpdateHandlerTest { private val firmwareRetriever: FirmwareRetriever = mockk() - private val serviceRepository: ServiceRepository = mockk() + private val radioController: RadioController = mockk() + private val nodeRepository: NodeRepository = mockk() private val centralManager: CentralManager = mockk() private val context: Context = mockk() private val contentResolver: ContentResolver = mockk() - private val handler = Esp32OtaUpdateHandler(firmwareRetriever, serviceRepository, centralManager, context) + private val handler = + Esp32OtaUpdateHandler(firmwareRetriever, radioController, nodeRepository, centralManager, context) @Before fun setUp() { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 4130e57f34..e0931fa210 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -87,9 +87,8 @@ import org.meshtastic.core.common.gpsDisabled import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.calculating @@ -344,7 +343,7 @@ fun MapView( LaunchedEffect(selectedWaypointId, waypoints) { if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) { - waypoints[selectedWaypointId]?.data?.waypoint?.let { pt -> + waypoints[selectedWaypointId]?.waypoint?.let { pt -> val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) map.controller.setCenter(geoPoint) map.controller.setZoom(WAYPOINT_ZOOM) @@ -496,7 +495,7 @@ fun MapView( fun showMarkerLongPressDialog(id: Int) { performHapticFeedback() Logger.d { "marker long pressed id=$id" } - val waypoint = waypoints[id]?.data?.waypoint ?: return + val waypoint = waypoints[id]?.waypoint ?: return // edit only when unlocked or lockedTo myNodeNum if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) { showEditWaypointDialog = waypoint @@ -512,13 +511,13 @@ fun MapView( } @Suppress("MagicNumber") - fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List { + fun MapView.onWaypointChanged(waypoints: Collection, selectedWaypointId: Int?): List { return waypoints.mapNotNull { waypoint -> - val pt = waypoint.data.waypoint ?: return@mapNotNull null + val pt = waypoint.waypoint ?: return@mapNotNull null if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else "" - val time = DateFormatter.formatDateTime(waypoint.received_time) - val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt()) + val time = DateFormatter.formatDateTime(waypoint.time) + val label = (pt.name ?: "") + " " + formatAgo((waypoint.time / 1000).toInt()) val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!)) val now = nowMillis val expireTimeMillis = (pt.expire ?: 0) * 1000L @@ -530,7 +529,7 @@ fun MapView( } MarkerWithLabel(this, label, emoji).apply { id = "${pt.id}" - title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" + title = "${pt.name} (${getUsername(waypoint.from)}$lock)" snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr" position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7) if (selectedWaypointId == pt.id) { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 2029e058d9..66b2e3b0c1 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -23,13 +23,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.RadioController import org.meshtastic.core.navigation.MapRoutes import org.meshtastic.core.prefs.map.MapPrefs -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig import javax.inject.Inject @@ -41,12 +41,12 @@ class MapViewModel constructor( mapPrefs: MapPrefs, packetRepository: PacketRepository, - private val nodeRepository: NodeRepository, - serviceRepository: ServiceRepository, + override val nodeRepository: NodeRepository, + radioController: RadioController, radioConfigRepository: RadioConfigRepository, buildConfigProvider: BuildConfigProvider, savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) { +) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute().waypointId) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 99725a8f8e..e23a6bcf60 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -98,7 +98,7 @@ import org.jetbrains.compose.resources.stringResource import org.json.JSONObject import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.mpsToKmph import org.meshtastic.core.model.util.mpsToMph @@ -272,7 +272,7 @@ fun MapView( val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint } val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle() val tracerouteSelection = diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 03a4cc8c50..d47db4035d 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -45,14 +45,14 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.meshtastic.core.data.model.CustomTileProviderConfig import org.meshtastic.core.data.repository.CustomTileProviderRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.model.RadioController import org.meshtastic.core.navigation.MapRoutes import org.meshtastic.core.prefs.map.GoogleMapsPrefs import org.meshtastic.core.prefs.map.MapPrefs -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.Config import java.io.File @@ -86,11 +86,11 @@ constructor( nodeRepository: NodeRepository, packetRepository: PacketRepository, radioConfigRepository: RadioConfigRepository, - serviceRepository: ServiceRepository, + radioController: RadioController, private val customTileProviderRepository: CustomTileProviderRepository, uiPreferencesDataSource: UiPreferencesDataSource, savedStateHandle: SavedStateHandle, -) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) { +) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) { private val _selectedWaypointId = MutableStateFlow(savedStateHandle.toRoute().waypointId) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() @@ -344,7 +344,7 @@ constructor( viewModelScope.launch { val wpMap = waypoints.first { it.containsKey(wpId) } wpMap[wpId]?.let { packet -> - val waypoint = packet.data.waypoint!! + val waypoint = packet.waypoint!! val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7) cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f) } @@ -643,6 +643,9 @@ constructor( super.onCleared() (currentTileProvider as? MBTilesProvider)?.close() } + + override fun getUser(userId: String?) = + nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) } enum class LayerType { diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt index f42d978af9..51d2764296 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.ui.component.NodeChip @Composable diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt index 1930438fc0..bea9865e20 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt @@ -18,7 +18,7 @@ package org.meshtastic.feature.map.model import com.google.android.gms.maps.model.LatLng import com.google.maps.android.clustering.ClusterItem -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node data class NodeClusterItem( val node: Node, diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 82edfb9bb3..d37715e476 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -16,10 +16,8 @@ */ package org.meshtastic.feature.map -import android.os.RemoteException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,60 +27,45 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.map.MapPrefs +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.any import org.meshtastic.core.resources.eight_hours import org.meshtastic.core.resources.one_day import org.meshtastic.core.resources.one_hour import org.meshtastic.core.resources.two_days -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.Position -import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint -@Suppress("MagicNumber") -sealed class LastHeardFilter(val seconds: Long, val label: StringResource) { - data object Any : LastHeardFilter(0L, Res.string.any) - - data object OneHour : LastHeardFilter(TimeConstants.ONE_HOUR.inWholeSeconds, Res.string.one_hour) - - data object EightHours : LastHeardFilter(TimeConstants.EIGHT_HOURS.inWholeSeconds, Res.string.eight_hours) - - data object OneDay : LastHeardFilter(TimeConstants.ONE_DAY.inWholeSeconds, Res.string.one_day) - - data object TwoDays : LastHeardFilter(TimeConstants.TWO_DAYS.inWholeSeconds, Res.string.two_days) - - companion object { - fun fromSeconds(seconds: Long): LastHeardFilter = entries.find { it.seconds == seconds } ?: Any - - val entries = listOf(Any, OneHour, EightHours, OneDay, TwoDays) - } -} - @Suppress("TooManyFunctions") abstract class BaseMapViewModel( protected val mapPrefs: MapPrefs, - private val nodeRepository: NodeRepository, + protected open val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, - private val serviceRepository: ServiceRepository, + private val radioController: RadioController, ) : ViewModel() { val myNodeInfo = nodeRepository.myNodeInfo + val ourNodeInfo = nodeRepository.ourNodeInfo + val myNodeNum get() = myNodeInfo.value?.myNodeNum val myId = nodeRepository.myId + val isConnected = + radioController.connectionState + .map { it is org.meshtastic.core.model.ConnectionState.Connected } + .stateInWhileSubscribed(initialValue = false) + val nodes: StateFlow> = nodeRepository .getNodes() @@ -94,78 +77,65 @@ abstract class BaseMapViewModel( .map { nodes -> nodes.filter { node -> node.validPosition != null } } .stateInWhileSubscribed(initialValue = emptyList()) - val waypoints: StateFlow> = + val waypoints: StateFlow> = packetRepository .getWaypoints() .mapLatest { list -> list - .associateBy { packet -> packet.data.waypoint!!.id } + .associateBy { packet -> packet.waypoint!!.id } .filterValues { - val expire = it.data.waypoint!!.expire ?: 0 + val expire = it.waypoint?.expire ?: 0 expire == 0 || expire.toLong() > nowSeconds } } .stateInWhileSubscribed(initialValue = emptyMap()) private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites) + val showOnlyFavoritesOnMap = showOnlyFavorites + + fun toggleOnlyFavorites() { + val newValue = !showOnlyFavorites.value + showOnlyFavorites.value = newValue + mapPrefs.showOnlyFavorites = newValue + } + + private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap) + val showWaypointsOnMap = showWaypoints - private val showWaypointsOnMap = MutableStateFlow(mapPrefs.showWaypointsOnMap) + fun toggleShowWaypointsOnMap() { + val newValue = !showWaypoints.value + showWaypoints.value = newValue + mapPrefs.showWaypointsOnMap = newValue + } - private val showPrecisionCircleOnMap = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap) + private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap) + val showPrecisionCircleOnMap = showPrecisionCircle - private val lastHeardFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter)) + fun toggleShowPrecisionCircleOnMap() { + val newValue = !showPrecisionCircle.value + showPrecisionCircle.value = newValue + mapPrefs.showPrecisionCircleOnMap = newValue + } - private val lastHeardTrackFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter)) + private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter)) + val lastHeardFilter = lastHeardFilterValue fun setLastHeardFilter(filter: LastHeardFilter) { + lastHeardFilterValue.value = filter mapPrefs.lastHeardFilter = filter.seconds - lastHeardFilter.value = filter } + private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter)) + val lastHeardTrackFilter = lastHeardTrackFilterValue + fun setLastHeardTrackFilter(filter: LastHeardFilter) { + lastHeardTrackFilterValue.value = filter mapPrefs.lastHeardTrackFilter = filter.seconds - lastHeardTrackFilter.value = filter - } - - val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo - - fun getNodeByNum(nodeNum: Int): Node? = nodeRepository.nodeDBbyNum.value[nodeNum] - - open fun getUser(userId: String?): User = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) - - fun getUser(nodeNum: Int): User = nodeRepository.getUser(nodeNum) - - fun getNodeOrFallback(nodeNum: Int): Node = getNodeByNum(nodeNum) ?: Node(num = nodeNum, user = getUser(nodeNum)) - - val isConnected = - serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false) - - fun toggleOnlyFavorites() { - val current = showOnlyFavorites.value - mapPrefs.showOnlyFavorites = !current - showOnlyFavorites.value = !current } - fun toggleShowWaypointsOnMap() { - val current = showWaypointsOnMap.value - mapPrefs.showWaypointsOnMap = !current - showWaypointsOnMap.value = !current - } + abstract fun getUser(userId: String?): org.meshtastic.proto.User - fun toggleShowPrecisionCircleOnMap() { - val current = showPrecisionCircleOnMap.value - mapPrefs.showPrecisionCircleOnMap = !current - showPrecisionCircleOnMap.value = !current - } - - fun generatePacketId(): Int? { - return try { - serviceRepository.meshService?.packetId - } catch (ex: RemoteException) { - Logger.e { "RemoteException: ${ex.message}" } - return null - } - } + fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) } @@ -179,13 +149,11 @@ abstract class BaseMapViewModel( } private fun sendDataPacket(p: DataPacket) { - try { - serviceRepository.meshService?.send(p) - } catch (ex: RemoteException) { - Logger.e { "Send DataPacket error: ${ex.message}" } - } + viewModelScope.launch(Dispatchers.IO) { radioController.sendMessage(p) } } + fun generatePacketId(): Int = radioController.getPacketId() + data class MapFilterState( val onlyFavorites: Boolean, val showWaypoints: Boolean, @@ -259,3 +227,17 @@ fun BaseMapViewModel.tracerouteNodeSelection( nodeLookup = nodesForLookup.associateBy { it.num }, ) } + +@Suppress("MagicNumber") +enum class LastHeardFilter(val label: StringResource, val seconds: Long) { + Any(Res.string.any, 0L), + OneHour(Res.string.one_hour, 3600L), + EightHours(Res.string.eight_hours, 28800L), + OneDay(Res.string.one_day, 86400L), + TwoDays(Res.string.two_days, 172800L), + ; + + companion object { + fun fromSeconds(seconds: Long): LastHeardFilter = entries.find { it.seconds == seconds } ?: Any + } +} diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt index 0fb5f6e18e..7a971417fa 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt @@ -29,10 +29,10 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.toList import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.prefs.map.MapPrefs +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.ui.util.toPosition import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.model.CustomTileSource diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index 10972edb3a..cbf7a8443c 100644 --- a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -40,14 +40,14 @@ import org.junit.Test import org.junit.runner.RunWith import org.meshtastic.core.data.model.CustomTileProviderConfig import org.meshtastic.core.data.repository.CustomTileProviderRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.map.GoogleMapsPrefs import org.meshtastic.core.prefs.map.MapPrefs -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @@ -60,7 +60,7 @@ class MapViewModelTest { private val nodeRepository = mockk(relaxed = true) private val packetRepository = mockk(relaxed = true) private val radioConfigRepository = mockk(relaxed = true) - private val serviceRepository = mockk(relaxed = true) + private val radioController = mockk(relaxed = true) private val customTileProviderRepository = mockk(relaxed = true) private val uiPreferencesDataSource = mockk(relaxed = true) private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null)) @@ -81,7 +81,7 @@ class MapViewModelTest { every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) every { nodeRepository.getNodes() } returns flowOf(emptyList()) every { packetRepository.getWaypoints() } returns flowOf(emptyList()) - every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) viewModel = MapViewModel( @@ -91,7 +91,7 @@ class MapViewModelTest { nodeRepository, packetRepository, radioConfigRepository, - serviceRepository, + radioController, customTileProviderRepository, uiPreferencesDataSource, savedStateHandle, diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index bc772a2648..6abacade74 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -24,7 +24,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.database.model.Message +import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider 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 91bda8f2e2..1f5c246265 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 @@ -102,9 +102,9 @@ import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer import org.meshtastic.core.database.entity.QuickChatAction -import org.meshtastic.core.database.model.Message -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Message +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.getChannel import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.alert_bell_text 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 25be104303..ab317a6f35 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 @@ -61,11 +61,10 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.entity.Reaction -import org.meshtastic.core.database.model.Message -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.new_messages_below import org.meshtastic.feature.messaging.component.MessageItem @@ -545,7 +544,7 @@ private fun MessageStatusDialog( remember(message.relayNode, nodes, ourNode) { derivedStateOf { message.relayNode?.let { relayNodeId -> - Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.long_name + Node.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.long_name } } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt index ee69b3547a..8f9c722857 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.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,10 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.messaging -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node /** Defines the various user interactions that can occur on the MessageScreen. */ internal sealed interface MessageScreenEvent { 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 174b485887..d7abd44747 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 @@ -32,21 +32,21 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.data.repository.QuickChatActionRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.ContactSettings -import org.meshtastic.core.database.model.Message -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.domain.usecase.SendMessageUseCase +import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Message +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs import org.meshtastic.core.prefs.ui.UiPrefs -import org.meshtastic.core.service.MeshServiceNotifications -import org.meshtastic.core.service.ServiceAction -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.usecase.SendMessageUseCase import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import javax.inject.Inject diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 115e3633e0..6dd60807e0 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -62,10 +62,10 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.database.entity.Reaction -import org.meshtastic.core.database.model.Message -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.filter_message_label import org.meshtastic.core.resources.message_delivery_status diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 0011e1e5c1..8055b97399 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -57,12 +57,11 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.entity.Reaction -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.getStringResFrom import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.model.util.getShortDateTime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.delivery_confirmed @@ -148,7 +147,9 @@ internal fun ReactionRow( AnimatedVisibility(emojiGroups.isNotEmpty(), modifier = modifier) { LazyRow(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - items(emojiGroups.entries.toList()) { (emoji, reactions) -> + items(emojiGroups.entries.toList()) { entry -> + val emoji = entry.key + val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } ReactionItem( emoji = emoji, @@ -218,7 +219,7 @@ internal fun ReactionDialog( val relayNodeName = reaction.relayNode?.let { relayNodeId -> - Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.long_name + Node.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.long_name } DeliveryInfo( @@ -236,7 +237,9 @@ internal fun ReactionDialog( } LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - items(groupedEmojis.entries.toList()) { (emoji, reactions) -> + items(groupedEmojis.entries.toList()) { entry -> + val emoji = entry.key + val reactions = entry.value val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } val isSending = localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt index 616765d1d8..58e54fcf9a 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt @@ -20,7 +20,7 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import org.meshtastic.core.domain.MessageQueue +import org.meshtastic.core.repository.MessageQueue import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue @Module diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt index 49d11fa105..ac4fd76a04 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt @@ -22,10 +22,10 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.PacketRepository @HiltWorker class SendMessageWorker @@ -47,18 +47,16 @@ constructor( return Result.retry() } - val packetEntity = + val packetData = packetRepository.getPacketByPacketId(packetId) ?: return Result.failure() // Packet no longer exists in DB? Do not retry. - val packetData = packetEntity.packet.data - return try { radioController.sendMessage(packetData) packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE) Result.success() } catch (e: Exception) { - packetRepository.updateMessageStatus(packetData, MessageStatus.ERROR) + packetRepository.updateMessageStatus(packetData, MessageStatus.QUEUED) Result.retry() } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt index a7b829be0d..dab1837e34 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt @@ -20,7 +20,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf -import org.meshtastic.core.domain.MessageQueue +import org.meshtastic.core.repository.MessageQueue import javax.inject.Inject import javax.inject.Singleton diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index b5b7016c8a..f256e23e20 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -65,8 +65,8 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.model.Contact +import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.model.util.formatMuteRemainingTime import org.meshtastic.core.model.util.getChannel diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 0826fe7131..2b645bac2c 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -28,16 +28,15 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.ContactSettings -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.Contact +import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.util.getChannel -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet import javax.inject.Inject @@ -59,7 +58,7 @@ constructor( val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet()) // Combine node info and myId to reduce argument count in subsequent combines - private val identityFlow: Flow> = + private val identityFlow: Flow> = combine(nodeRepository.myNodeInfo, nodeRepository.myId) { info, id -> Pair(info, id) } /** @@ -78,42 +77,42 @@ constructor( settings, -> val (myNodeInfo, myId) = identity - val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() + val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() // Add empty channel placeholders (always show Broadcast contacts, even when empty) val placeholder = (0 until channelSet.settings.size).associate { ch -> val contactKey = "$ch${DataPacket.ID_BROADCAST}" val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) - contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) + contactKey to data } - (contacts + (placeholder - contacts.keys)).values.collectionsMap { packet -> - val data = packet.data - val contactKey = packet.contact_key - + (contacts + (placeholder - contacts.keys)).entries.collectionsMap { entry -> + val contactKey = entry.key + val packetData = entry.value // Determine if this is my message (originated on this device) - val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)) - val toBroadcast = data.to == DataPacket.ID_BROADCAST + val fromLocal = + (packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId)) + val toBroadcast = packetData.to == DataPacket.ID_BROADCAST // grab usernames from NodeInfo - val userId = if (fromLocal) data.to else data.from + val userId = if (fromLocal) packetData.to else packetData.from val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) val shortName = user.short_name val longName = if (toBroadcast) { - channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}" + channelSet.getChannel(packetData.channel)?.name ?: "Channel ${packetData.channel}" } else { user.long_name } Contact( contactKey = contactKey, - shortName = if (toBroadcast) data.channel.toString() else shortName, + shortName = if (toBroadcast) packetData.channel.toString() else shortName, longName = longName, - lastMessageTime = if (data.time != 0L) data.time else null, - lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", + lastMessageTime = if (packetData.time != 0L) packetData.time else null, + lastMessageText = if (fromLocal) packetData.text else "$shortName: ${packetData.text}", unreadCount = packetRepository.getUnreadCount(contactKey), messageCount = packetRepository.getMessageCount(contactKey), isMuted = settings[contactKey]?.isMuted == true, @@ -140,36 +139,41 @@ constructor( val myId = params.myId packetRepository.getContactsPaged().map { pagingData -> - pagingData.map { packet -> - val data = packet.data - val contactKey = packet.contact_key + pagingData.map { packetData: DataPacket -> + val contactKey = + "${packetData.channel}${packetData.to}" // This might be wrong, need to check how contactKey + // is derived in PagingSource // Determine if this is my message (originated on this device) - val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)) - val toBroadcast = data.to == DataPacket.ID_BROADCAST + val fromLocal = + (packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId)) + val toBroadcast = packetData.to == DataPacket.ID_BROADCAST // grab usernames from NodeInfo - val userId = if (fromLocal) data.to else data.from + val userId = if (fromLocal) packetData.to else packetData.from val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) val shortName = user.short_name val longName = if (toBroadcast) { - channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}" + channelSet.getChannel(packetData.channel)?.name ?: "Channel ${packetData.channel}" } else { user.long_name } + val contactKeyComputed = + if (toBroadcast) "${packetData.channel}${DataPacket.ID_BROADCAST}" else contactKey + Contact( - contactKey = contactKey, - shortName = if (toBroadcast) data.channel.toString() else shortName, + contactKey = contactKeyComputed, + shortName = if (toBroadcast) packetData.channel.toString() else shortName, longName = longName, - lastMessageTime = if (data.time != 0L) data.time else null, - lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", - unreadCount = packetRepository.getUnreadCount(contactKey), - messageCount = packetRepository.getMessageCount(contactKey), - isMuted = settings[contactKey]?.isMuted == true, + lastMessageTime = if (packetData.time != 0L) packetData.time else null, + lastMessageText = if (fromLocal) packetData.text else "$shortName: ${packetData.text}", + unreadCount = packetRepository.getUnreadCount(contactKeyComputed), + messageCount = packetRepository.getMessageCount(contactKeyComputed), + isMuted = settings[contactKeyComputed]?.isMuted == true, isUnmessageable = user.is_unmessagable ?: false, nodeColors = if (!toBroadcast) { diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt index 48abe99de2..537bc1d63e 100644 --- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt +++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt @@ -30,17 +30,16 @@ import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.database.entity.Packet -import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.PacketRepository import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) @@ -62,11 +61,8 @@ class SendMessageWorkerTest { fun `doWork returns success when packet is sent successfully`() = runTest { // Arrange val packetId = 12345 - val dataPacket = DataPacket("dest", 0, "Hello") - val packet = mockk(relaxed = true) - val packetEntity = PacketEntity(packet = packet) - every { packet.data } returns dataPacket - coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity + val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) + coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected) coEvery { radioController.sendMessage(any()) } just Runs coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs @@ -99,11 +95,8 @@ class SendMessageWorkerTest { fun `doWork returns retry when radio is disconnected`() = runTest { // Arrange val packetId = 12345 - val dataPacket = DataPacket("dest", 0, "Hello") - val packet = mockk(relaxed = true) - val packetEntity = PacketEntity(packet = packet) - every { packet.data } returns dataPacket - coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity + val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) + coEvery { packetRepository.getPacketByPacketId(packetId) } returns dataPacket every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) val worker = diff --git a/feature/node/component/DeviceActions.kt b/feature/node/component/DeviceActions.kt index 39bb324c89..103558c7ef 100644 --- a/feature/node/component/DeviceActions.kt +++ b/feature/node/component/DeviceActions.kt @@ -55,7 +55,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.actions import org.meshtastic.core.resources.direct_message diff --git a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.kt b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.kt index 27416ceb12..e9b3c50540 100644 --- a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.kt +++ b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/component/InlineMap.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,12 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.node.component import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node @Composable internal fun InlineMap(node: Node, modifier: Modifier = Modifier) { diff --git a/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt b/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt index bb4c0fbe86..cb94e313f5 100644 --- a/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt +++ b/feature/node/src/google/kotlin/org/meshtastic/feature/node/component/InlineMap.kt @@ -31,7 +31,7 @@ import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.component.precisionBitsToMeters diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index fb1710ba24..3043ef4990 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -33,8 +33,8 @@ import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.latLongToMeter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.ui.component.precisionBitsToMeters import org.meshtastic.proto.Config diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 0fb96c836b..f127076d3f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -29,8 +29,9 @@ import androidx.compose.ui.graphics.Color import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.asDeviceVersion -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceVersion +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration @@ -41,7 +42,6 @@ import org.meshtastic.core.resources.latest_alpha_firmware import org.meshtastic.core.resources.latest_stable_firmware import org.meshtastic.core.resources.remote_admin import org.meshtastic.core.resources.request_metadata -import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index b96ad7927d..db10ed1757 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.actions import org.meshtastic.core.resources.direct_message diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt index 05cfd5fc53..e7ac4effd3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.model.util.UnitConversions.toTempString import org.meshtastic.core.model.util.toSmallDistanceString diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt index f0a35b489e..35e226b23e 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt @@ -41,7 +41,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.GPSFormat -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.metersIn import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 1ccd6c2780..61480cee6c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -55,8 +55,8 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.copy diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index fa431c8988..1e8e21b4be 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -60,7 +60,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.NodeSortOption +import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.desc_node_filter_clear import org.meshtastic.core.resources.node_filter_exclude_infrastructure diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index f8b8955526..0c30acc914 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -51,9 +51,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.isUnmessageableRole import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenuAction.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenuAction.kt index 6fa98374d3..b78dbdd299 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenuAction.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenuAction.kt @@ -16,7 +16,7 @@ */ package org.meshtastic.feature.node.component -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.TelemetryType sealed class NodeMenuAction { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt index 257ed0566b..d8b99c9c73 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NotesSection.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.add_a_note import org.meshtastic.core.resources.notes diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt index f4fe60fcc8..f4e3bb454b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt @@ -46,7 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.exchange_position diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt index 6927a7861c..ff361d8259 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 import org.meshtastic.core.resources.channel_2 diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index 0cee70ea87..d0955bf7f3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -47,7 +47,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.logs diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt index 24c00ff34f..8f4c9dd097 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt @@ -59,7 +59,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.FirmwareRelease -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.details diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 2790cd327b..8d6bb18aeb 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -32,12 +32,12 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.UiText -import org.meshtastic.core.service.ServiceAction -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase import org.meshtastic.feature.node.metrics.EnvironmentMetricsState diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index a84617dae8..fbf79a4d7a 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -16,14 +16,16 @@ */ package org.meshtastic.feature.node.detail -import android.os.RemoteException import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.favorite_add @@ -37,8 +39,6 @@ import org.meshtastic.core.resources.mute_remove import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_node_text import org.meshtastic.core.resources.unmute -import org.meshtastic.core.service.ServiceAction -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.util.AlertManager import javax.inject.Inject import javax.inject.Singleton @@ -49,6 +49,7 @@ class NodeManagementActions constructor( private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, + private val radioController: RadioController, private val alertManager: AlertManager, ) { fun requestRemoveNode(scope: CoroutineScope, node: Node) { @@ -62,13 +63,9 @@ constructor( fun removeNode(scope: CoroutineScope, nodeNum: Int) { scope.launch(Dispatchers.IO) { Logger.i { "Removing node '$nodeNum'" } - try { - val packetId = serviceRepository.meshService?.packetId ?: return@launch - serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) - nodeRepository.deleteNode(nodeNum) - } catch (ex: RemoteException) { - Logger.e { "Remove node error: ${ex.message}" } - } + val packetId = radioController.getPacketId() + radioController.removeByNodenum(packetId, nodeNum) + nodeRepository.deleteNode(nodeNum) } } @@ -88,13 +85,7 @@ constructor( } fun ignoreNode(scope: CoroutineScope, node: Node) { - scope.launch(Dispatchers.IO) { - try { - serviceRepository.onServiceAction(ServiceAction.Ignore(node)) - } catch (ex: RemoteException) { - Logger.e(ex) { "Ignore node error" } - } - } + scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) } } fun requestMuteNode(scope: CoroutineScope, node: Node) { @@ -110,13 +101,7 @@ constructor( } fun muteNode(scope: CoroutineScope, node: Node) { - scope.launch(Dispatchers.IO) { - try { - serviceRepository.onServiceAction(ServiceAction.Mute(node)) - } catch (ex: RemoteException) { - Logger.e(ex) { "Mute node error" } - } - } + scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) } } fun requestFavoriteNode(scope: CoroutineScope, node: Node) { @@ -135,13 +120,7 @@ constructor( } fun favoriteNode(scope: CoroutineScope, node: Node) { - scope.launch(Dispatchers.IO) { - try { - serviceRepository.onServiceAction(ServiceAction.Favorite(node)) - } catch (ex: RemoteException) { - Logger.e(ex) { "Favorite node error" } - } - } + scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) } } fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt index 2bad12fb92..63f3ebc45a 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Position +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText @@ -44,7 +45,6 @@ import org.meshtastic.core.resources.requesting_from import org.meshtastic.core.resources.signal_quality import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.user_info -import org.meshtastic.core.service.ServiceRepository import javax.inject.Inject import javax.inject.Singleton @@ -53,7 +53,7 @@ sealed class NodeRequestEffect { } @Singleton -class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) { +class NodeRequestActions @Inject constructor(private val radioController: RadioController) { private val _effects = MutableSharedFlow() val effects: SharedFlow = _effects.asSharedFlow() @@ -67,34 +67,26 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(Dispatchers.IO) { Logger.i { "Requesting UserInfo for '$destNum'" } - try { - serviceRepository.meshService?.requestUserInfo(destNum) - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName), - ), - ) - } catch (ex: android.os.RemoteException) { - Logger.e { "Request NodeInfo error: ${ex.message}" } - } + radioController.requestUserInfo(destNum) + _effects.emit( + NodeRequestEffect.ShowFeedback( + UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName), + ), + ) } } fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(Dispatchers.IO) { Logger.i { "Requesting NeighborInfo for '$destNum'" } - try { - val packetId = serviceRepository.meshService?.packetId ?: return@launch - serviceRepository.meshService?.requestNeighborInfo(packetId, destNum) - _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName), - ), - ) - } catch (ex: android.os.RemoteException) { - Logger.e { "Request NeighborInfo error: ${ex.message}" } - } + val packetId = radioController.getPacketId() + radioController.requestNeighborInfo(packetId, destNum) + _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } + _effects.emit( + NodeRequestEffect.ShowFeedback( + UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName), + ), + ) } } @@ -106,61 +98,49 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv ) { scope.launch(Dispatchers.IO) { Logger.i { "Requesting position for '$destNum'" } - try { - serviceRepository.meshService?.requestPosition(destNum, position) - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.position, longName), - ), - ) - } catch (ex: android.os.RemoteException) { - Logger.e { "Request position error: ${ex.message}" } - } + radioController.requestPosition(destNum, position) + _effects.emit( + NodeRequestEffect.ShowFeedback( + UiText.Resource(Res.string.requesting_from, Res.string.position, longName), + ), + ) } } fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) { scope.launch(Dispatchers.IO) { Logger.i { "Requesting telemetry for '$destNum'" } - try { - val packetId = serviceRepository.meshService?.packetId ?: return@launch - serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal) + val packetId = radioController.getPacketId() + radioController.requestTelemetry(packetId, destNum, type.ordinal) - val typeRes = - when (type) { - TelemetryType.DEVICE -> Res.string.request_device_metrics - TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics - TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics - TelemetryType.POWER -> Res.string.request_power_metrics - TelemetryType.LOCAL_STATS -> Res.string.signal_quality - TelemetryType.HOST -> Res.string.request_host_metrics - TelemetryType.PAX -> Res.string.request_pax_metrics - } + val typeRes = + when (type) { + TelemetryType.DEVICE -> Res.string.request_device_metrics + TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics + TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics + TelemetryType.POWER -> Res.string.request_power_metrics + TelemetryType.LOCAL_STATS -> Res.string.signal_quality + TelemetryType.HOST -> Res.string.request_host_metrics + TelemetryType.PAX -> Res.string.request_pax_metrics + } - _effects.emit( - NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)), - ) - } catch (ex: android.os.RemoteException) { - Logger.e { "Request telemetry error: ${ex.message}" } - } + _effects.emit( + NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)), + ) } } fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(Dispatchers.IO) { Logger.i { "Requesting traceroute for '$destNum'" } - try { - val packetId = serviceRepository.meshService?.packetId ?: return@launch - serviceRepository.meshService?.requestTraceroute(packetId, destNum) - _lastTracerouteTimes.update { it + (destNum to nowMillis) } - _effects.emit( - NodeRequestEffect.ShowFeedback( - UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName), - ), - ) - } catch (ex: android.os.RemoteException) { - Logger.e { "Request traceroute error: ${ex.message}" } - } + val packetId = radioController.getPacketId() + radioController.requestTraceroute(packetId, destNum) + _lastTracerouteTimes.update { it + (destNum to nowMillis) } + _effects.emit( + NodeRequestEffect.ShowFeedback( + UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName), + ), + ) } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt index 1d11bad9b3..bf5b7e4f4b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt @@ -18,9 +18,9 @@ package org.meshtastic.feature.node.domain.usecase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.NodeSortOption +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.feature.node.list.NodeFilterState import org.meshtastic.feature.node.model.isEffectivelyUnmessageable import org.meshtastic.proto.Config diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt index 665dd1af6c..f5955c9f36 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt @@ -23,17 +23,17 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import org.meshtastic.core.data.repository.DeviceHardwareRepository import org.meshtastic.core.data.repository.FirmwareReleaseRepository import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.hasValidEnvironmentMetrics import org.meshtastic.core.model.util.isDirectSignal +import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.fallback_node_name @@ -110,7 +110,7 @@ constructor( nodeRepository.myNodeInfo, radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) }, ) { ourNode, myInfo, profile -> - IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile) + IdentityGroup(ourNode, myInfo, profile) } // 3. Metadata & Request Timestamps diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt index 1fdd315664..4af6eaaea6 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.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,12 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.node.list import kotlinx.coroutines.flow.map -import org.meshtastic.core.database.model.NodeSortOption import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.model.NodeSortOption import javax.inject.Inject class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index f2a823296d..bdaa2a97a7 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -67,8 +67,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.add_favorite import org.meshtastic.core.resources.channel_invalid diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index c90313ae7a..38e51602cd 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -17,11 +17,9 @@ package org.meshtastic.feature.node.list import android.net.Uri -import android.os.RemoteException import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -30,12 +28,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.NodeSortOption +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.dispatchMeshtasticUri -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase @@ -53,6 +52,7 @@ constructor( private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, + private val radioController: RadioController, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -154,11 +154,7 @@ constructor( radioConfigRepository.replaceAllSettings(channelSet.settings) val newLoraConfig = channelSet.lora_config if (newLoraConfig != null) { - try { - serviceRepository.meshService?.setConfig(Config(lora = newLoraConfig).encode()) - } catch (ex: RemoteException) { - Logger.e(ex) { "Set config error" } - } + radioController.setLocalConfig(Config(lora = newLoraConfig)) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 0f3e2820b5..5b8dea3b68 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -46,20 +46,20 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.TracerouteSnapshotRepository import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.Node import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.navigation.NodesRoutes +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.okay import org.meshtastic.core.resources.traceroute import org.meshtastic.core.resources.view_on_map -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.toMessageRes import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt index 14484e5304..8bbe50716b 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/IsEffectivelyUnmessageable.kt @@ -16,8 +16,8 @@ */ package org.meshtastic.feature.node.model -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.isUnmessageableRole +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.isUnmessageableRole val Node.isEffectivelyUnmessageable: Boolean get() = user.is_unmessagable ?: (user.role?.isUnmessageableRole() == true) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index 966aec158e..2833ada973 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -18,8 +18,8 @@ package org.meshtastic.feature.node.model import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.MeshLog -import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.Node import org.meshtastic.proto.Config import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.MeshPacket diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index e74440f912..1f93a15bae 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -16,9 +16,9 @@ */ package org.meshtastic.feature.node.model -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.navigation.Route -import org.meshtastic.core.service.ServiceAction import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.proto.Config diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 243cec17fa..05a0f59180 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -23,9 +23,10 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.proto.User @@ -34,6 +35,7 @@ class NodeManagementActionsTest { private val nodeRepository = mockk(relaxed = true) private val serviceRepository = mockk(relaxed = true) + private val radioController = mockk(relaxed = true) private val alertManager = mockk(relaxed = true) private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -42,6 +44,7 @@ class NodeManagementActionsTest { NodeManagementActions( nodeRepository = nodeRepository, serviceRepository = serviceRepository, + radioController = radioController, alertManager = alertManager, ) diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt index 1ddfba0f37..8ab7cbf063 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt @@ -24,9 +24,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.database.model.NodeSortOption +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.feature.node.list.NodeFilterState import org.meshtastic.proto.Config import org.meshtastic.proto.User diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt index d5fbcc31f5..477f1b5b48 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration import org.meshtastic.core.resources.preserve_favorites diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index a75296c13a..db8aceff7a 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -32,11 +32,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.DatabaseManager -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.model.Node import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase @@ -45,9 +40,14 @@ import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.core.prefs.ui.UiPrefs +import org.meshtastic.core.repository.DatabaseManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.LocalConfig import java.io.BufferedWriter @@ -77,7 +77,7 @@ constructor( private val exportDataUseCase: ExportDataUseCase, private val isOtaCapableUseCase: IsOtaCapableUseCase, ) : ViewModel() { - val myNodeInfo: StateFlow = nodeRepository.myNodeInfo + val myNodeInfo: StateFlow = nodeRepository.myNodeInfo val myNodeNum get() = myNodeInfo.value?.myNodeNum @@ -170,7 +170,7 @@ constructor( */ @Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod") fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) { - viewModelScope.launch(Dispatchers.Main) { + viewModelScope.launch { val myNodeNum = myNodeNum ?: return@launch writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 161134b163..c58f342326 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -37,13 +37,13 @@ import org.meshtastic.core.common.util.nowInstant import org.meshtastic.core.common.util.toDate import org.meshtastic.core.common.util.toInstant import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toReadableString import org.meshtastic.core.prefs.meshlog.MeshLogPrefs +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.debug_clear import org.meshtastic.core.resources.debug_clear_logs_confirm diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt index 77c17699a6..cc263bfe13 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModel.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.meshtastic.core.prefs.filter.FilterPrefs -import org.meshtastic.core.service.filter.MessageFilterService +import org.meshtastic.core.repository.MessageFilter import javax.inject.Inject @HiltViewModel @@ -30,7 +30,7 @@ class FilterSettingsViewModel @Inject constructor( private val filterPrefs: FilterPrefs, - private val messageFilterService: MessageFilterService, + private val messageFilter: MessageFilter, ) : ViewModel() { private val _filterEnabled = MutableStateFlow(filterPrefs.filterEnabled) @@ -51,7 +51,7 @@ constructor( if (current.add(trimmed)) { filterPrefs.filterWords = current _filterWords.value = current.toList().sorted() - messageFilterService.rebuildPatterns() + messageFilter.rebuildPatterns() } } @@ -60,7 +60,7 @@ constructor( if (current.remove(word)) { filterPrefs.filterWords = current _filterWords.value = current.toList().sorted() - messageFilterService.rebuildPatterns() + messageFilter.rebuildPatterns() } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt index db9cd8fd5a..daa04a79d0 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.clean_node_database_description import org.meshtastic.core.resources.clean_node_database_title diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index d17df93ffb..15f1f6d053 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -24,8 +24,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.database.model.Node import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.are_you_sure import org.meshtastic.core.resources.clean_node_database_confirmation diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index bc61b70c4a..54b04c295c 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -43,11 +43,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.data.repository.LocationRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.model.Node import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -59,15 +54,20 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.prefs.analytics.AnalyticsPrefs import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs import org.meshtastic.core.prefs.map.MapConsentPrefs +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.cant_shutdown -import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute @@ -217,7 +217,7 @@ constructor( Logger.d { "RadioConfigViewModel created" } } - private val myNodeInfo: StateFlow + private val myNodeInfo: StateFlow get() = nodeRepository.myNodeInfo val myNodeNum diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt index 2176b32be1..92e4e84a71 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.Node +import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.send diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index 94b17c645d..7da9f7b3cd 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.getColorFrom -import org.meshtastic.core.database.model.getStringResFrom +import org.meshtastic.core.model.getColorFrom +import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.tak import org.meshtastic.core.resources.tak_config diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index 2a7c673e2d..55ae3ab755 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.database.model.isUnmessageableRole import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.hardware_model import org.meshtastic.core.resources.licensed_amateur_radio diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 9879d8903c..7e628b85bb 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -30,9 +30,6 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase @@ -44,8 +41,13 @@ import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase import org.meshtastic.core.model.RadioController import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.core.prefs.ui.UiPrefs +import org.meshtastic.core.repository.DatabaseManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.robolectric.annotation.Config @OptIn(ExperimentalCoroutinesApi::class) +@Config(sdk = [34]) class SettingsViewModelTest { private val testDispatcher = StandardTestDispatcher() @@ -58,14 +60,14 @@ class SettingsViewModelTest { private val databaseManager: DatabaseManager = mockk(relaxed = true) private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true) - private val setThemeUseCase: SetThemeUseCase = mockk(relaxed = true) - private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase = mockk(relaxed = true) - private val setProvideLocationUseCase: SetProvideLocationUseCase = mockk(relaxed = true) - private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase = mockk(relaxed = true) - private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase = mockk(relaxed = true) - private val meshLocationUseCase: MeshLocationUseCase = mockk(relaxed = true) - private val exportDataUseCase: ExportDataUseCase = mockk(relaxed = true) - private val isOtaCapableUseCase: IsOtaCapableUseCase = mockk(relaxed = true) + private lateinit var setThemeUseCase: SetThemeUseCase + private lateinit var setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase + private lateinit var setProvideLocationUseCase: SetProvideLocationUseCase + private lateinit var setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase + private lateinit var setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase + private lateinit var meshLocationUseCase: MeshLocationUseCase + private lateinit var exportDataUseCase: ExportDataUseCase + private lateinit var isOtaCapableUseCase: IsOtaCapableUseCase private lateinit var viewModel: SettingsViewModel @@ -73,6 +75,15 @@ class SettingsViewModelTest { fun setUp() { Dispatchers.setMain(testDispatcher) + setThemeUseCase = mockk(relaxed = true) + setAppIntroCompletedUseCase = mockk(relaxed = true) + setProvideLocationUseCase = mockk(relaxed = true) + setDatabaseCacheLimitUseCase = mockk(relaxed = true) + setMeshLogSettingsUseCase = mockk(relaxed = true) + meshLocationUseCase = mockk(relaxed = true) + exportDataUseCase = mockk(relaxed = true) + isOtaCapableUseCase = mockk(relaxed = true) + // Return real StateFlows to avoid ClassCastException every { databaseManager.cacheLimit } returns MutableStateFlow(100) every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt index b7a256bf4f..101cce4feb 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt +++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt @@ -33,8 +33,8 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.meshtastic.core.data.repository.MeshLogRepository -import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.prefs.meshlog.MeshLogPrefs +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.ui.util.AlertManager @OptIn(ExperimentalCoroutinesApi::class) diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt index 35fd61f2b1..40bb475eb0 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt +++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt @@ -23,12 +23,12 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.meshtastic.core.prefs.filter.FilterPrefs -import org.meshtastic.core.service.filter.MessageFilterService +import org.meshtastic.core.repository.MessageFilter class FilterSettingsViewModelTest { private val filterPrefs: FilterPrefs = mockk(relaxed = true) - private val messageFilterService: MessageFilterService = mockk(relaxed = true) + private val messageFilter: MessageFilter = mockk(relaxed = true) private lateinit var viewModel: FilterSettingsViewModel @@ -37,7 +37,7 @@ class FilterSettingsViewModelTest { every { filterPrefs.filterEnabled } returns true every { filterPrefs.filterWords } returns setOf("apple", "banana") - viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilterService = messageFilterService) + viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter) } @Test @@ -52,7 +52,7 @@ class FilterSettingsViewModelTest { viewModel.addFilterWord("cherry") verify { filterPrefs.filterWords = any() } - verify { messageFilterService.rebuildPatterns() } + verify { messageFilter.rebuildPatterns() } assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value) } @@ -61,7 +61,7 @@ class FilterSettingsViewModelTest { viewModel.removeFilterWord("apple") verify { filterPrefs.filterWords = any() } - verify { messageFilterService.rebuildPatterns() } + verify { messageFilter.rebuildPatterns() } assertEquals(listOf("banana"), viewModel.filterWords.value) } } diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt index 07beee89d2..23425895d8 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt +++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt @@ -30,8 +30,8 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -import org.meshtastic.core.database.model.Node import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase +import org.meshtastic.core.model.Node import org.meshtastic.core.ui.util.AlertManager @OptIn(ExperimentalCoroutinesApi::class) diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index cc45c7075b..adf6dd9ac2 100644 --- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -34,10 +34,6 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.meshtastic.core.data.repository.LocationRepository -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.data.repository.PacketRepository -import org.meshtastic.core.data.repository.RadioConfigRepository -import org.meshtastic.core.database.model.Node import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase @@ -48,10 +44,14 @@ import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase +import org.meshtastic.core.model.Node import org.meshtastic.core.prefs.analytics.AnalyticsPrefs import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs import org.meshtastic.core.prefs.map.MapConsentPrefs -import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config diff --git a/settings.gradle.kts b/settings.gradle.kts index 0db4cf6c06..5b8062b062 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ include( ":core:nfc", ":core:prefs", ":core:proto", + ":core:repository", ":core:service", ":core:resources", ":core:ui",