feat: service extraction (#4828)

This commit is contained in:
James Rich
2026-03-17 14:06:01 -05:00
committed by GitHub
parent 0d0bdf9172
commit 807db83f53
76 changed files with 309 additions and 257 deletions

View File

@@ -152,7 +152,7 @@
<!-- This is the public API for doing mesh radio operations from android apps -->
<service
android:name="org.meshtastic.app.service.MeshService"
android:name="org.meshtastic.core.service.MeshService"
android:enabled="true"
android:foregroundServiceType="connectedDevice|location"
android:exported="true" tools:ignore="ExportedActivity">
@@ -228,7 +228,7 @@
android:resource="@xml/device_filter" />
</activity>
<receiver android:name="org.meshtastic.app.service.BootCompleteReceiver"
<receiver android:name="org.meshtastic.core.service.BootCompleteReceiver"
android:exported="false">
<!-- handle boot events -->
<intent-filter>
@@ -252,9 +252,9 @@
android:path="com.geeksville.mesh" /> -->
</intent-filter>
</receiver>
<receiver android:name="org.meshtastic.app.service.ReplyReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.app.service.MarkAsReadReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.app.service.ReactionReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.ReplyReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.MarkAsReadReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.ReactionReceiver" android:exported="false" />
<receiver
android:name="org.meshtastic.app.widget.LocalStatsWidgetReceiver"

View File

@@ -25,13 +25,13 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.meshtastic.app.service.MeshService
import org.meshtastic.app.service.startService
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.MeshService
import org.meshtastic.core.service.ServiceClient
import org.meshtastic.core.service.startService
/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */
@Factory

View File

@@ -40,10 +40,10 @@ import org.koin.core.context.startKoin
import org.meshtastic.app.di.AppKoinModule
import org.meshtastic.app.di.module
import org.meshtastic.app.widget.LocalStatsWidgetReceiver
import org.meshtastic.app.worker.MeshLogCleanupWorker
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.service.worker.MeshLogCleanupWorker
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration

View File

@@ -37,14 +37,15 @@ import org.meshtastic.core.database.di.CoreDatabaseAndroidModule
import org.meshtastic.core.database.di.CoreDatabaseModule
import org.meshtastic.core.datastore.di.CoreDatastoreAndroidModule
import org.meshtastic.core.datastore.di.CoreDatastoreModule
import org.meshtastic.core.network.di.CoreNetworkAndroidModule
import org.meshtastic.core.network.di.CoreNetworkModule
import org.meshtastic.core.network.repository.ProbeTableProvider
import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceAndroidModule
import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.connections.repository.ProbeTableProvider
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
@@ -72,6 +73,7 @@ import org.meshtastic.feature.settings.di.FeatureSettingsModule
CoreServiceModule::class,
CoreServiceAndroidModule::class,
CoreNetworkModule::class,
CoreNetworkAndroidModule::class,
CoreUiModule::class,
FeatureNodeModule::class,
FeatureMessagingModule::class,

View File

@@ -22,6 +22,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.service.worker.SendMessageWorker
/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
@Single

View File

@@ -74,7 +74,6 @@ import org.meshtastic.app.navigation.firmwareGraph
import org.meshtastic.app.navigation.mapGraph
import org.meshtastic.app.navigation.nodesGraph
import org.meshtastic.app.navigation.settingsGraph
import org.meshtastic.app.service.MeshService
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.DeviceVersion
@@ -96,6 +95,7 @@ import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.navigation.icon

View File

@@ -0,0 +1,5 @@
# Track extract_services_20260317 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View File

@@ -0,0 +1,8 @@
{
"track_id": "extract_services_20260317",
"type": "refactor",
"status": "new",
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z",
"description": "Extract service/worker/radio files from `app` to `core:service/androidMain` and `core:network/androidMain`"
}

View File

@@ -0,0 +1,44 @@
# Implementation Plan: Extract service/worker/radio files from `app`
## Phase 1: Preparation & Analysis [checkpoint: 72022ed]
- [x] Task: Identify all Android-specific classes to be moved (Services, WorkManager workers, Radio connections in `app`) [fd916e3]
- [ ] Locate `Service` classes in `app/src/main/java/org/meshtastic/app`
- [ ] Locate WorkManager `Worker` classes
- [ ] Locate Radio connection classes
- [x] Task: Conductor - User Manual Verification 'Preparation & Analysis' (Protocol in workflow.md)
## Phase 2: Extraction to `core:service` [checkpoint: ff47af8]
- [x] Task: Setup `core:service` module for Android and Common targets (if not already fully configured) [a114084]
- [x] Task: Move Android `Service` implementations to `core:service/androidMain` [965def0]
- [x] Move the files
- [x] Update imports and Koin injections
- [x] Task: Abstract shared service logic into `core:service/commonMain` [a85e282]
- [x] Write failing tests for abstracted shared logic (TDD Red)
- [x] Extract interfaces and platform-agnostic logic (TDD Green)
- [x] Refactor the implementations to use these shared abstractions
- [x] Task: Conductor - User Manual Verification 'Extraction to core:service' (Protocol in workflow.md)
## Phase 3: Extraction to `core:network` [checkpoint: 97a5b62]
- [x] Task: Move Radio connection and networking files from `app` to `core:network/androidMain` [b5233cf]
- [x] Move the files
- [x] Update imports and Koin injections
- [x] Task: Abstract shared radio/network logic into `core:network/commonMain` [cc1581d]
- [x] Write failing tests for abstracted radio logic (TDD Red)
- [x] Extract platform-agnostic business logic (TDD Green)
- [x] Refactor implementations to use shared abstractions
- [x] Task: Conductor - User Manual Verification 'Extraction to core:network' (Protocol in workflow.md)
## Phase 4: Desktop Integration [checkpoint: fffcedc]
- [x] Task: Integrate newly extracted shared abstractions into the `desktop` module [f39df2f]
- [x] Implement desktop-specific actuals or Koin bindings for the shared interfaces
- [x] Wire up abstracted services/radio logic in desktop Koin graph
- [x] Task: Conductor - User Manual Verification 'Desktop Integration' (Protocol in workflow.md)
## Phase 5: Verification & Cleanup [checkpoint: a0866e0]
- [x] Task: Build project and verify no regressions in background processing or radio connectivity [a9edc2e]
- [x] Task: Verify test coverage (>80%) for all extracted and refactored code [9cff9bc]
- [x] Task: Remove any lingering unused dependencies or dead code in `app` [e39d2e2]
- [x] Task: Conductor - User Manual Verification 'Verification & Cleanup' (Protocol in workflow.md)
## Phase: Review Fixes
- [x] Task: Apply review suggestions [1ae9fb6]

View File

@@ -0,0 +1,32 @@
# Specification: Extract service/worker/radio files from `app`
## Overview
This track aims to decouple the main `app` module by extracting Android-specific service, WorkManager worker, and radio connection files into `core:service` and `core:network` modules. The goal is to maximize code reuse across Kotlin Multiplatform (KMP) targets, clarify class responsibilities, and improve unit testability by isolating the network and service layers.
## Goals
- **Decouple `app`:** Remove Android-specific service dependencies from the main app module.
- **KMP Preparation:** Migrate as much logic as possible into `commonMain` for reuse across platforms.
- **Desktop Integration:** If logic is successfully abstracted into `commonMain`, integrate and use it within the `desktop` target to ensure reusability.
- **Testability:** Isolate service and network layers to facilitate better unit testing.
- **Simplification:** Refactor logic during the move to clarify and simplify responsibilities.
## Functional Requirements
- Identify all service, worker, and radio-related classes currently residing in the `app` module.
- Move Android-specific implementations (e.g., `Service`, `Worker`) to `core:service/androidMain` and `core:network/androidMain`.
- Extract platform-agnostic business logic and interfaces into `commonMain` within those core modules.
- Refactor existing logic where necessary to establish a clear delineation of responsibility.
- Update all dependency injections (Koin modules) and imports across the project to reflect the new locations.
- Attempt to wire up the newly abstracted shared logic within the `desktop` module if applicable.
## Non-Functional Requirements
- **Architecture Compliance:** Changes must adhere to the MVI / Unidirectional Data Flow and KMP structures defined in `tech-stack.md`.
- **Performance:** Refactoring should not negatively impact app startup time or background processing efficiency.
- **Code Coverage:** Maintain or improve overall test coverage for the extracted components (>80% target).
## Acceptance Criteria
- [ ] No service, worker, or radio connection classes remain in the `app` module.
- [ ] Extracted Android-specific classes compile successfully in `core:service/androidMain` and `core:network/androidMain`.
- [ ] Shared business logic compiles successfully in `core:service/commonMain` and `core:network/commonMain`.
- [ ] If logic is abstracted for reuse, it is integrated and utilized in the `desktop` target where applicable.
- [ ] The app compiles, installs, and runs without regressions in background processing or radio connectivity.
- [ ] Unit tests for the moved and refactored classes pass.

View File

@@ -20,6 +20,6 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
- Device configuration and firmware updates
## Key Architecture Goals
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`) to support multiple platforms (Android, Desktop, iOS)
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
- Ensure offline-first functionality and resilient data persistence (Room KMP)
- Decouple UI logic into shared components (`core:ui`, `feature:*`) using Compose Multiplatform

View File

@@ -7,6 +7,9 @@
- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop.
- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android.
## Background & Services
- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary.
## Architecture
- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`.
- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`.

View File

@@ -1,5 +1,3 @@
# Project Tracks
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
---

View File

@@ -258,7 +258,7 @@ class CommandSenderImpl(
wantAck = true,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true),
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum),
),
)
}
@@ -296,7 +296,7 @@ class CommandSenderImpl(
to = destNum,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true),
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum),
),
)
}
@@ -349,7 +349,7 @@ class CommandSenderImpl(
wantAck = true,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true),
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum),
),
)
}

View File

@@ -52,4 +52,9 @@ data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
}
override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
companion object {
const val MIN_FW_VERSION = "2.5.14"
const val ABS_MIN_FW_VERSION = "2.3.15"
}
}

View File

@@ -51,7 +51,10 @@ kotlin {
val jvmMain by getting { dependencies { implementation(libs.ktor.client.java) } }
androidMain.dependencies {
implementation(projects.core.ble)
implementation(projects.core.prefs)
implementation(libs.org.eclipse.paho.client.mqttv3)
implementation(libs.usb.serial.android)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.svg)
implementation(libs.ktor.client.okhttp)

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import android.app.Application
import android.provider.Settings
@@ -37,8 +37,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.util.BinaryLogFile
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
@@ -49,11 +49,11 @@ 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.network.repository.NetworkRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.feature.connections.repository.NetworkRepository
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
@@ -73,6 +73,7 @@ class AndroidRadioInterfaceService(
private val dispatchers: CoroutineDispatchers,
private val bluetoothRepository: BluetoothRepository,
private val networkRepository: NetworkRepository,
private val buildConfigProvider: BuildConfigProvider,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
private val radioPrefs: RadioPrefs,
private val interfaceFactory: Lazy<InterfaceFactory>,
@@ -187,7 +188,7 @@ class AndroidRadioInterfaceService(
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
override fun isMockInterface(): Boolean =
BuildConfig.DEBUG || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
override fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one

View File

@@ -14,7 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
package org.meshtastic.core.network.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.model.InterfaceId

View File

@@ -14,14 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.network.repository.SerialConnection
import org.meshtastic.core.network.repository.SerialConnectionListener
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.SerialConnection
import org.meshtastic.feature.connections.repository.SerialConnectionListener
import org.meshtastic.feature.connections.repository.UsbRepository
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */

View File

@@ -14,11 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.UsbRepository
/** Factory for creating `SerialInterface` instances. */
@Single

View File

@@ -14,13 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import android.hardware.usb.UsbManager
import com.hoho.android.usbserial.driver.UsbSerialDriver
import org.koin.core.annotation.Single
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.UsbRepository
/** Serial/USB interface backend implementation. */
@Single

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.handledLaunch

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
import android.net.ConnectivityManager
import android.net.Network

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
import android.net.ConnectivityManager
import android.net.nsd.NsdManager

View File

@@ -14,7 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
@file:Suppress("SwallowedException")
package org.meshtastic.core.network.repository
import android.annotation.SuppressLint
import android.net.nsd.NsdManager

View File

@@ -14,7 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
@file:Suppress("MagicNumber")
package org.meshtastic.core.network.repository
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
import com.hoho.android.usbserial.driver.ProbeTable

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
/** USB serial connection. */
interface SerialConnection : AutoCloseable {

View File

@@ -14,7 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
@file:Suppress("MagicNumber")
package org.meshtastic.core.network.repository
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
/** Callbacks indicating state changes in the USB serial connection. */
interface SerialConnectionListener {

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
import android.app.Application
import android.hardware.usb.UsbDevice

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import io.mockk.coEvery
import io.mockk.every

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.meshtastic.core.repository.RadioTransport

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.meshtastic.core.repository.RadioTransport

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
package org.meshtastic.core.network.repository
object NetworkConstants {
const val SERVICE_PORT = 4403

View File

@@ -36,6 +36,7 @@ kotlin {
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.prefs)
implementation(projects.core.proto)

View File

@@ -14,15 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.koin.core.annotation.Single
import org.meshtastic.app.messaging.domain.worker.SendMessageWorker
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.service.worker.SendMessageWorker
@Single
class AndroidMeshWorkerManager(private val workManager: WorkManager) : MeshWorkerManager {

View File

@@ -200,7 +200,7 @@ class AndroidRadioControllerImpl(
// Ensure service is running/restarted to handle the new address
val intent =
android.content.Intent().apply {
setClassName("com.geeksville.mesh", "org.meshtastic.app.service.MeshService")
setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService")
}
context.startForegroundService(intent)
}

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import org.meshtastic.core.api.MeshtasticIntent

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.app.Service
import android.content.Context
@@ -27,12 +27,8 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.inject
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
@@ -44,17 +40,12 @@ 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.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.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.proto.PortNum
@Suppress("TooManyFunctions", "LargeClass")
@@ -64,21 +55,17 @@ class MeshService : Service() {
private val serviceRepository: ServiceRepository by inject()
private val packetHandler: PacketHandler by inject()
private val serviceBroadcasts: ServiceBroadcasts by inject()
private val nodeManager: NodeManager by inject()
private val messageProcessor: MeshMessageProcessor by inject()
private val commandSender: CommandSender by inject()
private val locationManager: MeshLocationManager by inject()
private val connectionManager: MeshConnectionManager by inject()
private val serviceNotifications: MeshServiceNotifications by inject()
private val orchestrator: MeshServiceOrchestrator by inject()
private val router: MeshRouter by inject()
@@ -102,8 +89,8 @@ class MeshService : Service() {
startService(context)
}
val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION)
val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION)
val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION)
val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)
}
override fun onCreate() {
@@ -121,29 +108,13 @@ class MeshService : Service() {
throw e
}
Logger.i { "Creating mesh service" }
serviceNotifications.initChannels()
packetHandler.start(serviceScope)
router.start(serviceScope)
nodeManager.start(serviceScope)
connectionManager.start(serviceScope)
messageProcessor.start(serviceScope)
commandSender.start(serviceScope)
serviceScope.handledLaunch { radioInterfaceService.connect() }
radioInterfaceService.receivedData
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) }
.launchIn(serviceScope)
serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(serviceScope)
nodeManager.loadCachedNodeDB()
orchestrator.start()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val a = radioInterfaceService.getDeviceAddress()
val wantForeground = a != null && a != NO_DEVICE_SELECTED
val wantForeground = a != null && a != "n"
val notification = connectionManager.updateStatusNotification() as android.app.Notification
@@ -207,6 +178,7 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
orchestrator.stop()
serviceJob.cancel()
super.onDestroy()
}

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.app.Notification
import android.app.NotificationChannel
@@ -40,11 +40,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.Single
import org.meshtastic.app.MainActivity
import org.meshtastic.app.R.raw
import org.meshtastic.app.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import org.meshtastic.app.service.ReactionReceiver.Companion.REACT_ACTION
import org.meshtastic.app.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
@@ -55,6 +50,7 @@ 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.R.raw
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
@@ -87,6 +83,9 @@ 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.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import org.meshtastic.core.service.ReactionReceiver.Companion.REACT_ACTION
import org.meshtastic.core.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
@@ -453,7 +452,7 @@ class MeshServiceNotificationsImpl(
val summaryNotification =
commonBuilder(NotificationType.DirectMessage)
.setSmallIcon(org.meshtastic.app.R.drawable.app_icon)
.setSmallIcon(context.applicationInfo.icon)
.setStyle(messagingStyle)
.setGroup(GROUP_KEY_MESSAGES)
.setGroupSummary(true)
@@ -697,14 +696,17 @@ class MeshServiceNotificationsImpl(
// region Helper/Builder Methods
private val openAppIntent: PendingIntent by lazy {
val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }
val intent =
Intent(context, Class.forName("org.meshtastic.app.MainActivity")).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun createOpenMessageIntent(contactKey: String): PendingIntent {
val deepLinkUri = "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()
val deepLinkIntent =
Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply {
Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
@@ -717,7 +719,7 @@ class MeshServiceNotificationsImpl(
private fun createOpenWaypointIntent(waypointId: Int): PendingIntent {
val deepLinkUri = "$DEEP_LINK_BASE_URI/map?waypointId=$waypointId".toUri()
val deepLinkIntent =
Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply {
Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
@@ -730,7 +732,7 @@ class MeshServiceNotificationsImpl(
private fun createOpenNodeDetailIntent(nodeNum: Int): PendingIntent {
val deepLinkUri = "$DEEP_LINK_BASE_URI/node?destNum=$nodeNum".toUri()
val deepLinkIntent =
Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply {
Intent(Intent.ACTION_VIEW, deepLinkUri, context, Class.forName("org.meshtastic.app.MainActivity")).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
@@ -811,7 +813,7 @@ class MeshServiceNotificationsImpl(
type: NotificationType,
contentIntent: PendingIntent? = null,
): NotificationCompat.Builder {
val smallIcon = org.meshtastic.app.R.drawable.app_icon
val smallIcon = context.applicationInfo.icon
return NotificationCompat.Builder(context, type.channelId)
.setSmallIcon(smallIcon)

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Context
@@ -23,8 +23,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import org.meshtastic.app.BuildConfig
import org.meshtastic.app.worker.ServiceKeepAliveWorker
import org.meshtastic.core.service.worker.ServiceKeepAliveWorker
// / Helper function to start running our service
fun MeshService.Companion.startService(context: Context) {
@@ -36,7 +35,7 @@ fun MeshService.Companion.startService(context: Context) {
// Before binding we want to explicitly create - so the service stays alive forever (so it can keep
// listening for the bluetooth packets arriving from the radio. And when they arrive forward them
// to Signal or whatever.
Logger.i { "Trying to start service debug=${BuildConfig.DEBUG}" }
Logger.i { "Trying to start service debug=${false}" }
val intent = createIntent(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.content.BroadcastReceiver
import android.content.Context

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
package org.meshtastic.core.service
import android.content.Context
import android.content.Intent

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.worker
package org.meshtastic.core.service.worker
import android.content.Context
import androidx.work.CoroutineWorker

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.messaging.domain.worker
package org.meshtastic.core.service.worker
import android.content.Context
import androidx.work.CoroutineWorker

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.worker
package org.meshtastic.core.service.worker
import android.app.Notification
import android.content.Context
@@ -26,11 +26,10 @@ import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import co.touchlab.kermit.Logger
import org.koin.android.annotation.KoinWorker
import org.meshtastic.app.R
import org.meshtastic.app.service.MeshService
import org.meshtastic.app.service.startService
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.startService
/**
* A worker whose sole purpose is to start the MeshService from the background. This is used as a fallback when
@@ -81,7 +80,7 @@ class ServiceKeepAliveWorker(
// We use "my_service" which matches NotificationType.ServiceState.channelId in MeshServiceNotificationsImpl
return NotificationCompat.Builder(applicationContext, "my_service")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setSmallIcon(applicationContext.applicationInfo.icon)
.setContentTitle("Resuming Mesh Service")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)

View File

@@ -22,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
@@ -42,6 +43,7 @@ import org.meshtastic.core.repository.ServiceRepository
* All injected dependencies are `commonMain` interfaces with real implementations in `core:data`.
*/
@Suppress("LongParameterList")
@Single
class MeshServiceOrchestrator(
private val radioInterfaceService: RadioInterfaceService,
private val serviceRepository: ServiceRepository,

View File

@@ -0,0 +1,77 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
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.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshServiceOrchestratorTest {
@Test
fun testStartWiresComponents() {
val radioInterfaceService = mockk<RadioInterfaceService>(relaxed = true)
val serviceRepository = mockk<ServiceRepository>(relaxed = true)
val packetHandler = mockk<PacketHandler>(relaxed = true)
val nodeManager = mockk<NodeManager>(relaxed = true)
val messageProcessor = mockk<MeshMessageProcessor>(relaxed = true)
val commandSender = mockk<CommandSender>(relaxed = true)
val connectionManager = mockk<MeshConnectionManager>(relaxed = true)
val router = mockk<MeshRouter>(relaxed = true)
val serviceNotifications = mockk<MeshServiceNotifications>(relaxed = true)
every { radioInterfaceService.receivedData } returns MutableSharedFlow()
every { serviceRepository.serviceAction } returns MutableSharedFlow()
val orchestrator =
MeshServiceOrchestrator(
radioInterfaceService,
serviceRepository,
packetHandler,
nodeManager,
messageProcessor,
commandSender,
connectionManager,
router,
serviceNotifications,
)
assertFalse(orchestrator.isRunning)
orchestrator.start()
assertTrue(orchestrator.isRunning)
verify { serviceNotifications.initChannels() }
verify { packetHandler.start(any()) }
verify { nodeManager.loadCachedNodeDB() }
orchestrator.stop()
assertFalse(orchestrator.isRunning)
}
}

View File

@@ -49,11 +49,11 @@ import org.koin.core.context.startKoin
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.radio.DesktopMeshServiceController
import org.meshtastic.desktop.ui.DesktopMainScreen
import org.meshtastic.desktop.ui.navSavedStateConfig
import java.util.Locale
@@ -82,7 +82,7 @@ fun main() = application(exitProcessOnExit = false) {
val systemLocale = remember { Locale.getDefault() }
// Start the mesh service processing chain (desktop equivalent of Android's MeshService)
val meshServiceController = remember { koinApp.koin.get<DesktopMeshServiceController>() }
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }
DisposableEffect(Unit) {
meshServiceController.start()
onDispose { meshServiceController.stop() }

View File

@@ -41,7 +41,6 @@ import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.desktop.radio.DesktopMeshServiceController
import org.meshtastic.desktop.radio.DesktopRadioInterfaceService
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
@@ -151,19 +150,6 @@ private fun desktopPlatformStubsModule() = module {
single<org.meshtastic.feature.node.compass.MagneticFieldProvider> { NoopMagneticFieldProvider() }
// Desktop mesh service controller — replaces Android's MeshService lifecycle
single {
DesktopMeshServiceController(
radioInterfaceService = get(),
serviceRepository = get(),
messageProcessor = get(),
connectionManager = get(),
packetHandler = get(),
router = get(),
nodeManager = get(),
commandSender = get(),
)
}
// Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android)
single<HttpClient> { HttpClient(Java) { install(ContentNegotiation) { json(get<Json>()) } } }

View File

@@ -1,110 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
/**
* Desktop equivalent of Android's `MeshService.onCreate()`.
*
* Starts the full message-processing chain that connects the radio transport layer to the business logic:
* ```
* radioInterfaceService.receivedData
* → messageProcessor.handleFromRadio(bytes, myNodeNum)
* → FromRadioPacketHandler → MeshRouter/PacketHandler/etc.
* ```
*
* On Android this chain runs inside an Android `Service` (foreground service with notifications). On Desktop there is
* no Android Service concept, so this controller manages the same lifecycle in-process, started at app launch time.
*/
@Suppress("LongParameterList")
class DesktopMeshServiceController(
private val radioInterfaceService: RadioInterfaceService,
private val serviceRepository: ServiceRepository,
private val messageProcessor: MeshMessageProcessor,
private val connectionManager: MeshConnectionManager,
private val packetHandler: PacketHandler,
private val router: MeshRouter,
private val nodeManager: NodeManager,
private val commandSender: CommandSender,
) {
private var serviceScope: CoroutineScope? = null
/**
* Starts the mesh service processing chain.
*
* This should be called once at application startup (after Koin is initialized). It mirrors the initialization
* logic from `MeshService.onCreate()`.
*/
@Suppress("InjectDispatcher")
fun start() {
if (serviceScope != null) {
Logger.w { "DesktopMeshServiceController: Already started, ignoring duplicate start()" }
return
}
Logger.i { "DesktopMeshServiceController: Starting mesh service processing chain" }
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
serviceScope = scope
// Start all processing components (same order as MeshService.onCreate)
packetHandler.start(scope)
router.start(scope)
nodeManager.start(scope)
connectionManager.start(scope)
messageProcessor.start(scope)
commandSender.start(scope)
// Auto-connect to saved device address (mirrors MeshService.onCreate)
scope.handledLaunch { radioInterfaceService.connect() }
// Wire the data flow: radio → message processor
radioInterfaceService.receivedData
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) }
.launchIn(scope)
// Wire service actions to the router
serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(scope)
// Load any cached node database
nodeManager.loadCachedNodeDB()
Logger.i { "DesktopMeshServiceController: Processing chain started" }
}
/** Stops the mesh service processing chain and cancels all coroutines. */
fun stop() {
Logger.i { "DesktopMeshServiceController: Stopping" }
serviceScope?.cancel("DesktopMeshServiceController stopped")
serviceScope = null
}
}

View File

@@ -120,7 +120,7 @@ Based on the latest codebase investigation, the following steps are proposed to
- Parity tests exist in `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`).
- Remaining parity work is documented in [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md): serializer registration validation and platform exception tracking.
## Remaining App-Only ViewModels
## App Module Thinning Status
All major ViewModels have now been extracted to `commonMain` and no longer rely on Android-specific subclasses. Platform-specific dependencies (like `android.net.Uri` or Location permissions) have been successfully isolated behind injected `core:repository` interfaces (e.g., `FileService`, `LocationService`).
@@ -133,6 +133,18 @@ Extracted to shared `commonMain` (no longer app-only):
- `ChannelViewModel``feature:settings/commonMain`
- `NodeMapViewModel``feature:map/commonMain`
Extracted to core KMP modules (Android-specific implementations):
- Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain`
- BLE, USB/Serial, TCP radio connections, and NsdManager → `core:network/androidMain`
Remaining to be extracted from `:app` to achieve a true thin-shell module:
- Navigation routes (`ChannelsNavigation.kt`, `SettingsNavigation.kt`, etc.)
- Android App Widgets (`LocalStatsWidget.kt`, `AndroidAppWidgetUpdater.kt`)
- Message Queue implementation (`WorkManagerMessageQueue.kt`)
- Location provider bindings (`AndroidMeshLocationManager.kt`)
- Top-level UI composition (`ui/Main.kt`, `ui/node/AdaptiveNodeListScreen.kt`)
- Root Activity and Koin bootstrapping (`MainActivity.kt`, `MeshUtilApplication.kt`, `MeshServiceClient.kt`)
## Prerelease Dependencies
| Dependency | Version | Why |

View File

@@ -87,7 +87,8 @@ These items address structural gaps identified in the March 2026 architecture re
1. **App module thinning** — Extracted ChannelViewModel, NodeMapViewModel, NodeContextMenu, EmptyDetailPlaceholder to shared modules.
-**Done:** Extracted remaining 5 ViewModels: `SettingsViewModel`, `RadioConfigViewModel`, `DebugViewModel`, `MetricsViewModel`, `UIViewModel` to shared KMP modules.
- **Next:** Extract service/worker/radio files from `app` to `core:service/androidMain` and `core:network/androidMain`.
- **Done:** Extracted service, worker, and radio files from `app` to `core:service/androidMain` and `core:network/androidMain`.
- **Next:** Extract remaining Android-specific files (e.g., Navigation files, App Widgets, message queues, and root Activity logic) out of `:app` to establish a truly thin app module.
2. **Serial/USB transport** — direct radio connection on Desktop via jSerialComm
3. **MQTT transport** — cloud relay operation (KMP, benefits all targets)
4. **Evaluate KMP-native mocking** — Evaluate `mockative` or similar to replace `mockk` in `commonMain` of `core:testing` for iOS readiness.

View File

@@ -50,6 +50,7 @@ kotlin {
implementation(projects.core.service)
implementation(projects.core.ui)
implementation(projects.core.ble)
implementation(projects.core.network)
implementation(projects.feature.settings)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)

View File

@@ -27,12 +27,12 @@ import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.connections.model.AndroidUsbDeviceData
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
import org.meshtastic.feature.connections.repository.UsbRepository
@KoinViewModel
@Suppress("LongParameterList", "TooManyFunctions")

View File

@@ -28,6 +28,9 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.resources.Res
@@ -38,9 +41,6 @@ import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.model.DiscoveredDevices
import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase
import org.meshtastic.feature.connections.model.getMeshtasticShortName
import org.meshtastic.feature.connections.repository.NetworkRepository
import org.meshtastic.feature.connections.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.feature.connections.repository.UsbRepository
import java.util.Locale
@Suppress("LongParameterList")

View File

@@ -50,6 +50,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.network.repository.NetworkConstants
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
@@ -60,7 +61,6 @@ import org.meshtastic.core.resources.no_network_devices_found
import org.meshtastic.core.resources.recent_network_devices
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.model.DeviceListEntry
import org.meshtastic.feature.connections.repository.NetworkConstants
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View File

@@ -134,7 +134,7 @@ class MainActivity : ComponentActivity() {
Log.i(TAG, "Found service in package: ${serviceInfo.packageName}")
} else {
Log.w(TAG, "No service found for action com.geeksville.mesh.Service. Falling back to default.")
intent.setClassName("com.geeksville.mesh", "org.meshtastic.app.service.MeshService")
intent.setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService")
}
val success = bindService(intent, serviceConnection, BIND_AUTO_CREATE)