refactor: BLE transport and UI for Kotlin Multiplatform unification (#4911)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-24 21:15:51 -05:00 committed by GitHub
parent b0e91a390c
commit 6516287c62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 429 additions and 845 deletions

View file

@ -61,6 +61,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -42,7 +42,7 @@ scanner.startScan()
<!--region graph-->
```mermaid
graph TB
:core:barcode[barcode]:::compose-desktop-application
:core:barcode[barcode]:::android-library
:core:barcode -.-> :core:resources
:core:barcode -.-> :core:ui
@ -55,6 +55,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -16,6 +16,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -33,6 +33,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -29,6 +29,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -36,6 +36,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -29,6 +29,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -30,6 +30,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -42,6 +42,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -26,7 +26,7 @@ navController.navigate(MessagingRoutes.Chat(nodeId = 12345))
<!--region graph-->
```mermaid
graph TB
:core:navigation[navigation]:::compose-desktop-application
:core:navigation[navigation]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@ -37,6 +37,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -1,7 +1,7 @@
# `:core:network`
## Overview
The `:core:network` module handles all internet-based communication, including fetching firmware metadata, device hardware definitions, and map tiles (in the `fdroid` flavor).
The `:core:network` module handles all internet-based communication, including fetching firmware metadata, device hardware definitions, and map tiles (in the `fdroid` flavor). It also provides the shared radio transport layer (`TCPInterface`, `SerialTransport`, `BleRadioInterface`).
## Key Components
@ -12,6 +12,12 @@ The module uses **Ktor** as its primary HTTP client for high-performance, asynch
- **`FirmwareReleaseRemoteDataSource`**: Fetches the latest firmware versions from GitHub or Meshtastic's metadata servers.
- **`DeviceHardwareRemoteDataSource`**: Fetches definitions for supported Meshtastic hardware devices.
### 3. Shared Transports
- **`BleRadioInterface`**: Multiplatform BLE transport powered by Kable.
- **`TCPInterface`**: Multiplatform TCP transport.
- **`SerialTransport`**: JVM-shared USB/Serial transport powered by jSerialComm.
- **`BaseRadioTransportFactory`**: Common factory for instantiating the KMP transports.
## Module dependency graph
<!--region graph-->
@ -28,6 +34,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -37,6 +37,7 @@ kotlin {
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.ble)
implementation(libs.okio)
implementation(libs.kmqtt.client)
@ -57,11 +58,7 @@ kotlin {
}
}
androidMain.dependencies {
implementation(projects.core.ble)
implementation(projects.core.prefs)
implementation(libs.usb.serial.android)
}
androidMain.dependencies { implementation(libs.usb.serial.android) }
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test)

View file

@ -19,31 +19,42 @@ package org.meshtastic.core.network.radio
import android.content.Context
import android.provider.Settings
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportFactory
/** Android implementation of [RadioTransportFactory] delegating to the legacy [InterfaceFactory]. */
@Single
/**
* Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory]
* while delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific
* [InterfaceFactory].
*/
@Single(binds = [RadioTransportFactory::class])
@Suppress("LongParameterList")
class AndroidRadioTransportFactory(
private val context: Context,
private val interfaceFactory: Lazy<InterfaceFactory>,
private val buildConfigProvider: BuildConfigProvider,
) : RadioTransportFactory {
scanner: BleScanner,
bluetoothRepository: BluetoothRepository,
connectionFactory: BleConnectionFactory,
dispatchers: CoroutineDispatchers,
) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) {
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
override fun isMockInterface(): Boolean =
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
interfaceFactory.value.createInterface(address, service)
override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address)
override fun isAddressValid(address: String?): Boolean = interfaceFactory.value.addressValid(address)
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport {
// Fallback to legacy factory for Serial, Mocks, and NOPs
return interfaceFactory.value.createInterface(address, service)
}
}

View file

@ -1,40 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.repository.RadioInterfaceService
/** Factory for creating `BleRadioInterface` instances. */
@Single
class BleRadioInterfaceFactory(
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
) {
fun create(rest: String, service: RadioInterfaceService): BleRadioInterface = BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = rest,
)
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** Bluetooth backend implementation. */
@Single
class BleRadioInterfaceSpec(private val factory: BleRadioInterfaceFactory) : InterfaceSpec<BleRadioInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): BleRadioInterface =
factory.create(rest, service)
/** Return true if this address is still acceptable. For Kable we don't strictly require prior bonding. */
override fun addressValid(rest: String): Boolean {
// We no longer strictly require the device to be in the bonded list before attempting connection,
// as Kable and Android will handle bonding seamlessly during connection/characteristic access if needed.
return rest.isNotBlank()
}
}

View file

@ -30,7 +30,6 @@ import org.meshtastic.core.repository.RadioTransport
@Single
class InterfaceFactory(
private val nopInterfaceFactory: NopInterfaceFactory,
private val bluetoothSpec: Lazy<BleRadioInterfaceSpec>,
private val mockSpec: Lazy<MockInterfaceSpec>,
private val serialSpec: Lazy<SerialInterfaceSpec>,
private val tcpSpec: Lazy<TCPInterfaceSpec>,
@ -40,7 +39,6 @@ class InterfaceFactory(
private val specMap: Map<InterfaceId, InterfaceSpec<*>>
get() =
mapOf(
InterfaceId.BLUETOOTH to bluetoothSpec.value,
InterfaceId.MOCK to mockSpec.value,
InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory),
InterfaceId.SERIAL to serialSpec.value,

View file

@ -0,0 +1,77 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportFactory
/**
* Common base class for platform [RadioTransportFactory] implementations. Handles KMP-friendly transports (BLE) while
* delegating platform-specific ones (like TCP, USB/Serial and Mocks) to the abstract [createPlatformTransport].
*/
abstract class BaseRadioTransportFactory(
protected val scanner: BleScanner,
protected val bluetoothRepository: BluetoothRepository,
protected val connectionFactory: BleConnectionFactory,
protected val dispatchers: CoroutineDispatchers,
) : RadioTransportFactory {
override fun isAddressValid(address: String?): Boolean {
val spec = address?.firstOrNull() ?: return false
return spec in
listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) ||
spec == '!' ||
isPlatformAddressValid(address)
}
protected open fun isPlatformAddressValid(address: String): Boolean = false
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
address.startsWith(InterfaceId.BLUETOOTH.id) -> {
BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
)
}
address.startsWith("!") -> {
BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address.removePrefix("!"),
)
}
else -> createPlatformTransport(address, service)
}
/** Delegate to platform for Mock, TCP, or Serial/USB interfaces. */
protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport
}

View file

@ -18,7 +18,6 @@
package org.meshtastic.core.network.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -47,6 +47,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
@ -70,7 +71,6 @@ private val SCAN_TIMEOUT = 5.seconds
* @param service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@SuppressLint("MissingPermission")
class BleRadioInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
@ -94,7 +94,9 @@ class BleRadioInterface(
}
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
CoroutineScope(
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
@ -104,7 +106,9 @@ class BleRadioInterface(
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
@Volatile private var isFullyConnected = false
@Suppress("VolatileModifier")
@Volatile
private var isFullyConnected = false
init {
connect()
@ -344,10 +348,10 @@ class BleRadioInterface(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
connectionScope.launch {
serviceScope.launch {
connectionScope.cancel()
bleConnection.disconnect()
service.onDisconnect(true)
connectionScope.cancel()
}
}

View file

@ -16,7 +16,7 @@ The shared capability contract for NFC scanning, injected via `CompositionLocalP
<!--region graph-->
```mermaid
graph TB
:core:nfc[nfc]:::compose-desktop-application
:core:nfc[nfc]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@ -27,6 +27,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -29,6 +29,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -32,6 +32,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -24,7 +24,7 @@ Text(text = stringResource(Res.string.your_string_key))
<!--region graph-->
```mermaid
graph TB
:core:resources[resources]:::compose-desktop-application
:core:resources[resources]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@ -35,6 +35,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -33,6 +33,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -15,180 +15,51 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
# `:core:testing` — Shared Test Doubles and Utilities
# `:core:testing`
## Purpose
## Module dependency graph
The `:core:testing` module provides lightweight, reusable test doubles (fakes, builders, factories) and testing utilities for **all** KMP modules. This module **consolidates testing dependencies** into a single, well-controlled location to:
<!--region graph-->
```mermaid
graph TB
:core:testing[testing]:::kmp-library
- **Reduce duplication**: Shared fakes (e.g., `FakeNodeRepository`, `FakeRadioController`) used across multiple modules.
- **Keep dependency graph clean**: All test doubles and libraries are defined once; modules depend on `:core:testing` instead of scattered test deps.
- **Enable KMP-wide test patterns**: Every module (`commonTest`, `androidUnitTest`, JVM tests) can reuse the same fakes.
- **Maintain purity**: Core business logic modules (e.g., `core:domain`, `core:data`) depend on `:core:testing` via `commonTest`, avoiding test-code leakage into production.
## Dependency Strategy
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
┌─────────────────────────────────────┐
│ core:testing │
│ (only deps: core:model, │
│ core:repository, test libs) │
└──────────────┬──────────────────────┘
│ (commonTest dependency)
┌──────┴─────────────┬────────────────────┐
│ │ │
core:domain feature:messaging feature:node
core:data feature:settings feature:firmware
(etc.) (etc.)
```
<!--endregion-->
### Target Compatibility Warning (March 2026 Audit)
## Overview
The `:core:testing` module is a dedicated **Kotlin Multiplatform (KMP)** library that provides shared test fakes, doubles, rules, and utilities. It is designed to be consumed by the `commonTest` source sets of all other KMP modules to ensure consistent and unified testing behavior across the codebase.
- **MockK Removal:** MockK has been removed from `commonMain` because it does not natively support Kotlin/Native (iOS).
- **Future-Proofing:** The project is migrating to `dev.mokkery` for KMP-compatible mocking or favoring manual fakes.
- **Recommendation:** Favor manual fakes (like `FakeNodeRepository`) in `commonMain` to maintain pure KMP portability.
By centralizing fakes and mocking utilities here, we prevent duplication of test setups and enforce a standard approach to testing ViewModels, Repositories, and pure domain logic.
### Key Design Rules
## Key Components
1. **`:core:testing` has NO dependencies on heavy modules**: It only depends on:
- `core:model` — Domain types (Node, User, etc.)
- `core:repository` — Interfaces (NodeRepository, etc.)
- Test libraries (`kotlin("test")`, `kotlinx.coroutines.test`, `turbine`, `junit`)
- **Test Doubles / Fakes**: Provides in-memory implementations of core repositories (e.g., `FakeNodeRepository`, `FakeMeshLogRepository`) to isolate components under test.
- **Coroutines Testing**: Provides dispatchers and test rules that replace the main dispatcher with `TestDispatcher` to allow time-control and synchronous execution of coroutines in tests.
- **Mokkery Support**: Integrated with the Mokkery compiler plugin to provide robust and unified mocking capabilities in `commonTest`.
2. **No circular dependencies**: Modules that depend on `:core:testing` (in `commonTest`) cannot be dependencies of `:core:testing` itself.
3. **`:core:testing` is NOT part of the app bundle**: It's declared in `commonTest` sourceSet only, so it never appears in release APKs or final JARs.
## What's Included
### Test Doubles (Fakes)
#### `FakeRadioController`
A no-op implementation of `RadioController` for unit tests. Tracks method calls and state changes.
## Usage
Add this module to your `commonTest` source set dependencies in your KMP module's `build.gradle.kts`:
```kotlin
val radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(1, radioController.sentPackets.size)
```
#### `FakeNodeRepository`
An in-memory implementation of `NodeRepository` for isolated testing.
```kotlin
val nodeRepo = FakeNodeRepository()
nodeRepo.setNodes(TestDataFactory.createTestNodes(5))
assertEquals(5, nodeRepo.nodeDBbyNum.value.size)
```
### Test Builders & Factories
#### `TestDataFactory`
Factory methods for creating domain objects with sensible defaults.
```kotlin
val node = TestDataFactory.createTestNode(num = 42, longName = "Alice")
val nodes = TestDataFactory.createTestNodes(10)
```
### Test Utilities
#### Flow collection helper
```kotlin
val emissions = flow { emit(1); emit(2) }.toList()
assertEquals(listOf(1, 2), emissions)
```
## Usage Examples
### Testing a ViewModel (in `feature:messaging/src/commonTest`)
```kotlin
class MessageViewModelTest {
private val nodeRepository = FakeNodeRepository()
@Test
fun testLoadsNodesCorrectly() = runTest {
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
val viewModel = createViewModel(nodeRepository)
assertEquals(3, viewModel.nodeCount.value)
kotlin {
sourceSets {
commonTest.dependencies {
implementation(projects.core.testing)
}
}
}
```
### Testing a UseCase (in `core:domain/src/commonTest`)
```kotlin
class SendMessageUseCaseTest {
private val radioController = FakeRadioController()
@Test
fun testSendsPacket() = runTest {
val useCase = SendMessageUseCase(radioController)
useCase.sendMessage(testPacket)
assertEquals(1, radioController.sentPackets.size)
}
}
```
## Adding New Test Doubles
When adding a new fake to `:core:testing`:
1. **Implement the interface** from `core:model` or `core:repository`.
2. **Track side effects** (e.g., `sentPackets`, `calledMethods`) for test assertions.
3. **Provide test helpers** (e.g., `setNodes()`, `clear()`) to manipulate state.
4. **Document with examples** in the class KDoc.
Example:
```kotlin
/**
* A test double for [SomeRepository].
*/
class FakeSomeRepository : SomeRepository {
val callHistory = mutableListOf<String>()
override suspend fun doSomething(value: String) {
callHistory.add(value)
}
// Test helpers
fun getCallCount() = callHistory.size
fun clear() = callHistory.clear()
}
```
## Dependency Maintenance
### When adding a new module:
- If it has `commonTest` tests, add `implementation(projects.core.testing)` to its `commonTest.dependencies`.
- Do NOT add heavy modules (e.g., `core:database`) to `:core:testing`'s dependencies.
### When a test needs a mock:
- Check `:core:testing` first for an existing fake.
- If none exists, consider adding it there (if it's reusable) vs. using `mockk()` inline.
### When updating interfaces:
- Update corresponding fakes in `:core:testing` to match new method signatures.
- Keep fakes no-op; don't replicate business logic.
## Files
```
core/testing/
├── build.gradle.kts # Lightweight, minimal dependencies
├── README.md # This file
└── src/commonMain/kotlin/org/meshtastic/core/testing/
├── FakeRadioController.kt # RadioController test double
├── FakeNodeRepository.kt # NodeRepository test double
└── TestDataFactory.kt # Builders and factories
```
## See Also
- `AGENTS.md` §3B: KMP platform purity guidelines (relevant for test code).
- `docs/kmp-status.md`: KMP module status and targets.
- `.github/copilot-instructions.md`: Build and test commands.

View file

@ -49,7 +49,7 @@ MeshtasticResourceDialog(
<!--region graph-->
```mermaid
graph TB
:core:ui[ui]:::compose-desktop-application
:core:ui[ui]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@ -60,6 +60,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared shell for setting up global UI logic across platforms (Android, Desktop).
*
* This component handles deep linking, shared dialogs (via [MeshtasticCommonAppSetup]), and provides the global
* [MeshtasticSnackbarProvider]. Platform entry points should wrap their navigation layout inside this shell.
*/
@Composable
fun MeshtasticAppShell(
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
hostModifier: Modifier = Modifier.padding(bottom = 16.dp),
content: @Composable () -> Unit,
) {
LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { uri ->
val commonUri = CommonUri.parse(uri.uriString)
DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}
}
MeshtasticCommonAppSetup(
uiViewModel = uiViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)
MeshtasticSnackbarProvider(snackbarManager = uiViewModel.snackbarManager, hostModifier = hostModifier) { content() }
}