feat: Migrate project to Kotlin Multiplatform (KMP) architecture (#4738)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-06 20:43:45 -06:00 committed by GitHub
parent 182ad933f4
commit 0ce322a0f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
163 changed files with 1837 additions and 877 deletions

View file

@ -30,6 +30,7 @@ configure<LibraryExtension> {
dependencies {
implementation(project(":core:resources"))
implementation(projects.core.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)

View file

@ -66,6 +66,7 @@ import com.google.zxing.common.HybridBinarizer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.util.BarcodeScanner
import java.nio.ByteBuffer
import java.util.concurrent.Executors

View file

@ -66,6 +66,7 @@ import com.google.mlkit.vision.common.InputImage
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.util.BarcodeScanner
import java.util.concurrent.Executors
@Composable

View file

@ -15,37 +15,55 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.devtools.ksp)
}
configure<LibraryExtension> { namespace = "org.meshtastic.core.ble" }
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.ble"
androidResources.enable = false
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
api(libs.nordic.client.android)
api(libs.nordic.ble.env.android)
api(libs.nordic.ble.env.android.compose)
api(libs.nordic.common.scanner.ble)
api(libs.nordic.common.core)
implementation(libs.kermit)
implementation(libs.kotlinx.coroutines.core)
api(libs.javax.inject)
}
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.javax.inject)
implementation(libs.kermit)
implementation(libs.kotlinx.coroutines.core)
androidMain.dependencies {
implementation(libs.hilt.android)
api(libs.nordic.client.android)
api(libs.nordic.ble.env.android)
api(libs.nordic.ble.env.android.compose)
api(libs.nordic.common.scanner.ble)
api(libs.nordic.common.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.nordic.client.android.mock)
testImplementation(libs.nordic.client.core.mock)
testImplementation(libs.nordic.core.mock)
testImplementation(libs.androidx.lifecycle.testing)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.nordic.client.android.mock)
implementation(libs.nordic.client.core.mock)
implementation(libs.nordic.core.mock)
implementation(libs.androidx.lifecycle.testing)
}
}
}
dependencies { add("kspAndroid", libs.hilt.compiler) }

View file

@ -34,92 +34,85 @@ import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
/**
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
* discovery.
* An Android implementation of [BleConnection] using Nordic's [CentralManager].
*
* @param centralManager The Nordic [CentralManager] to use for connection.
* @param scope The [CoroutineScope] in which to monitor connection state.
* @param tag A tag for logging.
*/
class BleConnection(
class AndroidBleConnection(
private val centralManager: CentralManager,
private val scope: CoroutineScope,
private val tag: String = "BLE",
) {
/** The currently connected [Peripheral], or null if not connected. */
var peripheral: Peripheral? = null
private set
) : BleConnection {
private val _peripheral = MutableSharedFlow<Peripheral?>(replay = 1)
private var _device: AndroidBleDevice? = null
override val device: BleDevice?
get() = _device
/** A flow of the current peripheral. */
val peripheralFlow = _peripheral.asSharedFlow()
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
private val _connectionState = simpleSharedFlow<ConnectionState>()
/** A flow of [ConnectionState] changes for the current [peripheral]. */
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
private val _connectionState = simpleSharedFlow<BleConnectionState>()
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
private var stateJob: Job? = null
private var profileJob: Job? = null
/**
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
* Use [connectAndAwait] if you need to wait for the connection to be established.
*
* @param p The peripheral to connect to.
*/
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
override suspend fun connect(device: BleDevice) = withContext(NonCancellable) {
val androidDevice = device as AndroidBleDevice
stateJob?.cancel()
peripheral = p
_peripheral.emit(p)
_device = androidDevice
_deviceFlow.emit(androidDevice)
centralManager.connect(
peripheral = p,
peripheral = androidDevice.peripheral,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
stateJob =
p.state
androidDevice.peripheral.state
.onEach { state ->
Logger.d { "[$tag] Connection state changed to $state" }
val commonState =
when (state) {
is ConnectionState.Connecting -> BleConnectionState.Connecting
is ConnectionState.Connected -> BleConnectionState.Connected
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
}
if (state is ConnectionState.Connected) {
p.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(p)
androidDevice.peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(androidDevice)
}
_connectionState.emit(state)
androidDevice.updateState(state)
_connectionState.emit(commonState)
}
.launchIn(scope)
}
/**
* Connects to the given [Peripheral] and waits for a terminal state (Connected or Disconnected).
*
* @param p The peripheral to connect to.
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
* @param onRegister Optional block to run before connecting, allowing for profile registration.
* @return The final [ConnectionState].
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
*/
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long, onRegister: suspend () -> Unit = {}): ConnectionState {
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
onRegister()
connect(p)
connect(device)
return withTimeout(timeoutMs) {
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
connectionState.first { it is BleConnectionState.Connected || it is BleConnectionState.Disconnected }
}
}
@Suppress("TooGenericExceptionCaught")
private fun observePeripheralDetails(p: Peripheral) {
private fun observePeripheralDetails(androidDevice: AndroidBleDevice) {
val p = androidDevice.peripheral
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
@ -135,32 +128,24 @@ class BleConnection(
.launchIn(scope)
}
/** Disconnects from the current peripheral. */
suspend fun disconnect() = withContext(NonCancellable) {
override suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
profileJob?.cancel()
profileJob = null
peripheral?.disconnect()
peripheral = null
_peripheral.emit(null)
_device?.peripheral?.disconnect()
_device = null
_deviceFlow.emit(null)
}
/**
* Executes a block within a discovered profile. Handles peripheral readiness, discovery with a timeout, and cleans
* up the profile job if discovery fails.
*
* @param serviceUuid The UUID of the service to discover.
* @param timeout The duration to wait for discovery.
* @param block The block to execute with the discovered service.
*/
@Suppress("TooGenericExceptionCaught")
suspend fun <T> profile(
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: kotlin.time.Duration = 30.seconds,
setup: suspend CoroutineScope.(no.nordicsemi.kotlin.ble.client.RemoteService) -> T,
timeout: kotlin.time.Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T {
val p = peripheralFlow.first { it != null }!!
val androidDevice = deviceFlow.first { it != null } as AndroidBleDevice
val p = androidDevice.peripheral
val serviceReady = CompletableDeferred<T>()
profileJob?.cancel()
@ -170,9 +155,8 @@ class BleConnection(
val profileScope = this
p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
try {
val result = setup(service)
val result = setup(AndroidBleService(service))
serviceReady.complete(result)
// Keep the profile active until this launch scope (profileJob) is cancelled
awaitCancellation()
} catch (e: Throwable) {
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
@ -193,11 +177,17 @@ class BleConnection(
}
}
/** Returns the maximum write value length for the given write type. */
fun maximumWriteValueLength(writeType: WriteType): Int? = peripheral?.maximumWriteValueLength(writeType)
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
val nordicWriteType =
when (writeType) {
BleWriteType.WITH_RESPONSE -> WriteType.WITH_RESPONSE
BleWriteType.WITHOUT_RESPONSE -> WriteType.WITHOUT_RESPONSE
}
return _device?.peripheral?.maximumWriteValueLength(nordicWriteType)
}
/** Requests a new connection priority for the current peripheral. */
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
peripheral?.requestConnectionPriority(priority)
_device?.peripheral?.requestConnectionPriority(priority)
}
}

View file

@ -16,20 +16,15 @@
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import org.meshtastic.core.model.util.anonymize
import kotlinx.coroutines.CoroutineScope
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import javax.inject.Inject
import javax.inject.Singleton
/** A snapshot in time of the state of the bluetooth subsystem. */
data class BluetoothState(
/** Whether we have adequate permissions to query bluetooth state */
val hasPermissions: Boolean = false,
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a list of the currently bonded devices */
val bondedDevices: List<Peripheral> = emptyList(),
) {
override fun toString(): String =
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {
it.anonymize
}})"
/** An Android implementation of [BleConnectionFactory]. */
@Singleton
class AndroidBleConnectionFactory @Inject constructor(private val centralManager: CentralManager) :
BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection =
AndroidBleConnection(centralManager, scope, tag)
}

View file

@ -0,0 +1,63 @@
/*
* 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.ble
import android.annotation.SuppressLint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import no.nordicsemi.kotlin.ble.core.ConnectionState
/** An Android implementation of [BleDevice] that wraps a Nordic [Peripheral]. */
class AndroidBleDevice(val peripheral: Peripheral) : BleDevice {
override val name: String?
get() = peripheral.name
override val address: String
get() = peripheral.address
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
@Suppress("MissingPermission")
override val isBonded: Boolean
get() = peripheral.bondState.value == BondState.BONDED
override val isConnected: Boolean
get() = peripheral.isConnected
@SuppressLint("MissingPermission")
override suspend fun readRssi(): Int = peripheral.readRssi()
@SuppressLint("MissingPermission")
override suspend fun bond() {
peripheral.createBond()
}
/** Updates the connection state based on Nordic's [ConnectionState]. */
fun updateState(nordicState: ConnectionState) {
_state.value =
when (nordicState) {
is ConnectionState.Connecting -> BleConnectionState.Connecting
is ConnectionState.Connected -> BleConnectionState.Connected
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
}
}
}

View file

@ -19,33 +19,17 @@ package org.meshtastic.core.ble
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConjunctionFilterScope
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
import javax.inject.Inject
import kotlin.time.Duration
/**
* A wrapper around [CentralManager]'s scanning capabilities to provide a consistent and easy-to-use API for BLE
* scanning across the application.
* An Android implementation of [BleScanner] using Nordic's [CentralManager].
*
* @param centralManager The Nordic [CentralManager] to use for scanning.
*/
class BleScanner @Inject constructor(private val centralManager: CentralManager) {
class AndroidBleScanner @Inject constructor(private val centralManager: CentralManager) : BleScanner {
/**
* Scans for BLE devices.
*
* @param timeout The duration of the scan.
* @param filterBlock Optional filter configuration block.
* @return A [Flow] of discovered [Peripheral]s.
*/
fun scan(timeout: Duration, filterBlock: (ConjunctionFilterScope.() -> Unit)? = null): Flow<Peripheral> =
if (filterBlock != null) {
centralManager.scan(timeout, filterBlock)
} else {
centralManager.scan(timeout)
}
.distinctByPeripheral()
.map { it.peripheral }
override fun scan(timeout: Duration): Flow<BleDevice> =
centralManager.scan(timeout).distinctByPeripheral().map { AndroidBleDevice(it.peripheral) }
}

View file

@ -0,0 +1,22 @@
/*
* 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.ble
import no.nordicsemi.kotlin.ble.client.RemoteService
/** An Android implementation of [BleService] that wraps a Nordic [RemoteService]. */
class AndroidBleService(val service: RemoteService) : BleService

View file

@ -36,26 +36,18 @@ import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
import javax.inject.Singleton
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
/** Android implementation of [BluetoothRepository]. */
@Singleton
class BluetoothRepository
class AndroidBluetoothRepository
@Inject
constructor(
private val dispatchers: CoroutineDispatchers,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
private val androidEnvironment: AndroidEnvironment,
) {
private val _state =
MutableStateFlow(
BluetoothState(
// Assume we have permission until we get our initial state update to prevent premature
// notifications to the user.
hasPermissions = true,
),
)
val state: StateFlow<BluetoothState>
get() = _state.asStateFlow()
) : BluetoothRepository {
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true))
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
@ -63,25 +55,16 @@ constructor(
}
}
fun refreshState() {
override fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
override fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
/**
* Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding
* process is finished. After successful bonding, the repository's state is refreshed to include the new bonded
* device.
*
* @param peripheral The peripheral to bond with.
* @throws SecurityException if required Bluetooth permissions are not granted.
* @throws Exception if the bonding process fails.
*/
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
override suspend fun bond(device: BleDevice) {
val androidDevice = device as AndroidBleDevice
androidDevice.peripheral.createBond()
updateBluetoothState()
}
@ -100,16 +83,15 @@ constructor(
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<Peripheral> =
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<BleDevice> =
if (enabled && hasPerms) {
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral)
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral).map { AndroidBleDevice(it) }
} else {
emptyList()
}
/** @return true if the given address is currently bonded to the system. */
@SuppressLint("MissingPermission")
fun isBonded(address: String): Boolean {
override fun isBonded(address: String): Boolean {
val enabled = androidEnvironment.isBluetoothEnabled
val hasPerms = hasRequiredPermissions()
return if (enabled && hasPerms) {
@ -126,7 +108,6 @@ constructor(
androidEnvironment.isLocationPermissionGranted
}
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService =

View file

@ -0,0 +1,69 @@
/*
* 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.ble
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
/** Represents the type of write operation. */
enum class BleWriteType {
WITH_RESPONSE,
WITHOUT_RESPONSE,
}
/** Encapsulates a BLE connection to a [BleDevice]. */
interface BleConnection {
/** The currently connected [BleDevice], or null if not connected. */
val device: BleDevice?
/** A flow of the current device. */
val deviceFlow: SharedFlow<BleDevice?>
/** A flow of [BleConnectionState] changes. */
val connectionState: SharedFlow<BleConnectionState>
/** Connects to the given [BleDevice]. */
suspend fun connect(device: BleDevice)
/** Connects to the given [BleDevice] and waits for a terminal state. */
suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit = {},
): BleConnectionState
/** Disconnects from the current device. */
suspend fun disconnect()
/** Executes a block within a discovered profile. */
suspend fun <T> profile(
serviceUuid: Uuid,
timeout: Duration = 30.seconds,
setup: suspend CoroutineScope.(BleService) -> T,
): T
/** Returns the maximum write value length for the given write type. */
fun maximumWriteValueLength(writeType: BleWriteType): Int?
}
/** Represents a BLE service for commonMain. */
interface BleService {
// This will be expanded as needed, but for now we just need a common type to pass around.
}

View file

@ -0,0 +1,31 @@
/*
* 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.ble
import kotlinx.coroutines.CoroutineScope
/** A factory for creating [BleConnection] instances. */
interface BleConnectionFactory {
/**
* Creates a new [BleConnection] instance.
*
* @param scope The [CoroutineScope] in which to monitor connection state.
* @param tag A tag for logging.
* @return A new [BleConnection] instance.
*/
fun create(scope: CoroutineScope, tag: String): BleConnection
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
/** Represents the state of a BLE connection. */
sealed class BleConnectionState {
/** The peripheral is disconnected. */
object Disconnected : BleConnectionState()
/** The peripheral is connecting. */
object Connecting : BleConnectionState()
/** The peripheral is connected. */
object Connected : BleConnectionState()
/** The peripheral is disconnecting. */
object Disconnecting : BleConnectionState()
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import kotlinx.coroutines.flow.StateFlow
/** Represents a BLE device. */
interface BleDevice {
/** The device's name. */
val name: String?
/** The device's address. */
val address: String
/** The current connection state of the device. */
val state: StateFlow<BleConnectionState>
/** Whether the device is bonded. */
val isBonded: Boolean
/** Whether the device is currently connected. */
val isConnected: Boolean
/** Reads the current RSSI value. */
suspend fun readRssi(): Int
/** Bond the device. */
suspend fun bond()
}

View file

@ -0,0 +1,31 @@
/*
* 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.ble
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
/** A scanner for BLE devices. */
interface BleScanner {
/**
* Scans for BLE devices.
*
* @param timeout The duration of the scan.
* @return A [Flow] of discovered [BleDevice]s.
*/
fun scan(timeout: Duration): Flow<BleDevice>
}

View file

@ -0,0 +1,49 @@
/*
* 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.ble
import kotlinx.coroutines.flow.StateFlow
/** Repository responsible for Bluetooth availability and bonding. */
interface BluetoothRepository {
/** The current state of Bluetooth on the device. */
val state: StateFlow<BluetoothState>
/** Refreshes the Bluetooth state. */
fun refreshState()
/** Returns true if the given address is valid. */
fun isValid(bleAddress: String): Boolean
/** Returns true if the given address is bonded. */
fun isBonded(address: String): Boolean
/** Initiates bonding with the given device. */
suspend fun bond(device: BleDevice)
}
/** Represents the state of Bluetooth on the device. */
data class BluetoothState(
/** True if the application has the required Bluetooth permissions. */
val hasPermissions: Boolean = false,
/** True if Bluetooth is enabled on the device. */
val enabled: Boolean = false,
/** A list of bonded devices. */
val bondedDevices: List<BleDevice> = emptyList(),
)

View file

@ -1,52 +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.ble
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BleModule {
@Provides
@Singleton
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
@Provides
@Singleton
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
CoroutineScope(SupervisorJob() + dispatchers.default)
}

View file

@ -152,7 +152,6 @@ constructor(
if (queueJob?.isActive == true) return
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break

View file

@ -14,34 +14,44 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.devtools.ksp)
}
configure<LibraryExtension> {
buildFeatures { aidl = true }
namespace = "org.meshtastic.core.service"
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.service"
androidResources.enable = false
}
testOptions { unitTests.isReturnDefaultValues = true }
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
}
androidMain.dependencies {
api(projects.core.api)
implementation(libs.hilt.android)
}
commonTest.dependencies {
implementation(libs.junit)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
implementation(libs.turbine)
}
}
}
dependencies {
api(projects.core.api)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.turbine)
}
dependencies { add("kspAndroid", libs.hilt.compiler) }

View file

@ -1,38 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.AndroidRadioControllerImpl
import org.meshtastic.core.service.AndroidServiceRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds @Singleton
abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
@Binds @Singleton
abstract fun bindServiceRepository(impl: AndroidServiceRepository): ServiceRepository
}

View file

@ -19,7 +19,6 @@ import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.hilt)
}
@ -27,8 +26,6 @@ configure<LibraryExtension> { namespace = "org.meshtastic.core.ui" }
dependencies {
implementation(projects.core.common)
implementation(projects.core.barcode)
implementation(projects.core.nfc)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
@ -37,7 +34,6 @@ dependencies {
implementation(projects.core.service)
implementation(projects.core.resources)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)

View file

@ -39,8 +39,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.import_label
@ -60,6 +58,8 @@ import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.openNfcSettings
import org.meshtastic.proto.SharedContact
@ -98,17 +98,18 @@ fun MeshtasticImportFAB(
var showNfcDisabledDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val barcodeScanner = rememberBarcodeScanner(onResult = { contents -> contents?.toUri()?.let { onImport(it) } })
val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.toUri()?.let { onImport(it) } }
val nfcScanner = LocalNfcScannerProvider.current
if (isNfcScanning) {
NfcScannerEffect(
onResult = { contents ->
nfcScanner(
{ contents ->
contents?.toUri()?.let {
onImport(it)
isNfcScanning = false
}
},
onNfcDisabled = {
{
isNfcScanning = false
showNfcDisabledDialog = true
},

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -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.core.barcode
package org.meshtastic.core.ui.util
interface BarcodeScanner {
fun startScan()

View file

@ -0,0 +1,22 @@
/*
* 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.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
val LocalAnalyticsIntroProvider = compositionLocalOf<@Composable () -> Unit> { {} }

View file

@ -0,0 +1,31 @@
/*
* 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.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
val LocalBarcodeScannerProvider =
compositionLocalOf<@Composable (onResult: (String?) -> Unit) -> BarcodeScanner> {
{
object : BarcodeScanner {
override fun startScan() {
// Default NO-OP
}
}
}
}

View file

@ -0,0 +1,23 @@
/*
* 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.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
val LocalNfcScannerProvider =
compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }

View file

@ -0,0 +1,43 @@
/*
* 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.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
/**
* Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map
* implementations (Google Maps vs osmdroid).
*/
interface MapViewProvider {
@Composable
fun MapView(
modifier: Modifier,
// We use Any here to avoid circular dependency with feature:map
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
// Using List<Any> to avoid dependency on proto.Position if needed
nodeTracks: List<Any>? = null,
tracerouteOverlay: Any? = null,
tracerouteNodePositions: Map<Int, Any> = emptyMap(),
onTracerouteMappableCountChanged: (Int, Int) -> Unit = { _, _ -> },
)
}
val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }