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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 309 additions and 257 deletions

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

@ -1,60 +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.feature.connections.repository
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
internal fun ConnectivityManager.networkAvailable(): Flow<Boolean> =
observeNetworks().map { activeNetworksList -> activeNetworksList.isNotEmpty() }.distinctUntilChanged()
internal fun ConnectivityManager.observeNetworks(
networkRequest: NetworkRequest = NetworkRequest.Builder().build(),
): Flow<List<Network>> = callbackFlow {
// Keep track of the current active networks
val activeNetworks = mutableSetOf<Network>()
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
activeNetworks.add(network)
trySend(activeNetworks.toList())
}
override fun onLost(network: Network) {
activeNetworks.remove(network)
trySend(activeNetworks.toList())
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
if (activeNetworks.contains(network)) {
trySend(activeNetworks.toList())
}
}
}
registerNetworkCallback(networkRequest, callback)
awaitClose { unregisterNetworkCallback(callback) }
}

View file

@ -1,77 +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.feature.connections.repository
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Single
class NetworkRepository(
private val nsdManager: NsdManager,
private val connectivityManager: ConnectivityManager,
private val dispatchers: CoroutineDispatchers,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
) {
val networkAvailable: Flow<Boolean> by lazy {
connectivityManager
.networkAvailable()
.flowOn(dispatchers.io)
.conflate()
.shareIn(
scope = processLifecycle.coroutineScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 1,
)
.distinctUntilChanged()
}
val resolvedList: Flow<List<NsdServiceInfo>> by lazy {
nsdManager
.serviceList(NetworkConstants.SERVICE_TYPE)
.flowOn(dispatchers.io)
.conflate()
.shareIn(
scope = processLifecycle.coroutineScope,
started = SharingStarted.WhileSubscribed(5000),
replay = 1,
)
}
companion object {
fun NsdServiceInfo.toAddressString() = buildString {
@Suppress("DEPRECATION")
append(host.hostAddress)
if (serviceType.trim('.') == NetworkConstants.SERVICE_TYPE && port != NetworkConstants.SERVICE_PORT) {
append(":$port")
}
}
}
}

View file

@ -1,156 +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.feature.connections.repository
import android.annotation.SuppressLint
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
@OptIn(ExperimentalCoroutinesApi::class)
internal fun NsdManager.serviceList(serviceType: String): Flow<List<NsdServiceInfo>> =
discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } }
private fun NsdManager.discoverServices(
serviceType: String,
protocolType: Int = NsdManager.PROTOCOL_DNS_SD,
): Flow<List<NsdServiceInfo>> = callbackFlow {
val serviceList = CopyOnWriteArrayList<NsdServiceInfo>()
val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
cancel("Start Discovery failed: Error code: $errorCode")
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
cancel("Stop Discovery failed: Error code: $errorCode")
}
override fun onDiscoveryStarted(serviceType: String) {
Logger.d { "NSD Service discovery started" }
}
override fun onDiscoveryStopped(serviceType: String) {
Logger.d { "NSD Service discovery stopped" }
close()
}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
Logger.d { "NSD Service found: $serviceInfo" }
serviceList += serviceInfo
trySend(serviceList)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
Logger.d { "NSD Service lost: $serviceInfo" }
serviceList.removeAll { it.serviceName == serviceInfo.serviceName }
trySend(serviceList)
}
}
trySend(emptyList()) // Emit an initial empty list
discoverServices(serviceType, protocolType, discoveryListener)
awaitClose {
try {
stopServiceDiscovery(discoveryListener)
} catch (ex: IllegalArgumentException) {
// ignore if discovery is already stopped
}
}
}
@SuppressLint("NewApi")
private suspend fun NsdManager.resolveService(serviceInfo: NsdServiceInfo): NsdServiceInfo? =
suspendCancellableCoroutine { continuation ->
val isResumed = AtomicBoolean(false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val callback =
object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
if (isResumed.compareAndSet(false, true)) {
continuation.resume(null)
}
}
override fun onServiceUpdated(updatedServiceInfo: NsdServiceInfo) {
if (updatedServiceInfo.hostAddresses.isNotEmpty()) {
if (isResumed.compareAndSet(false, true)) {
continuation.resume(updatedServiceInfo)
try {
unregisterServiceInfoCallback(this)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
}
}
}
}
override fun onServiceLost() {
if (isResumed.compareAndSet(false, true)) {
continuation.resume(null)
try {
unregisterServiceInfoCallback(this)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
}
}
}
override fun onServiceInfoCallbackUnregistered() {
// No op
}
}
registerServiceInfoCallback(serviceInfo, Dispatchers.Main.asExecutor(), callback)
continuation.invokeOnCancellation {
try {
unregisterServiceInfoCallback(callback)
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Already unregistered" }
}
}
} else {
val listener =
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
if (isResumed.compareAndSet(false, true)) {
continuation.resume(null)
}
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
if (isResumed.compareAndSet(false, true)) {
continuation.resume(serviceInfo)
}
}
}
@Suppress("DEPRECATION")
resolveService(serviceInfo, listener)
}
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.repository
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
import com.hoho.android.usbserial.driver.ProbeTable
import com.hoho.android.usbserial.driver.UsbSerialProber
import org.koin.core.annotation.Single
/**
* Creates a probe table for the USB driver. This augments the default device-to-driver mappings with additional known
* working configurations. See this package's README for more info.
*/
@Single
class ProbeTableProvider {
fun get(): ProbeTable = UsbSerialProber.getDefaultProbeTable().apply {
// RAK 4631:
addProduct(9114, 32809, CdcAcmSerialDriver::class.java)
// LilyGo TBeam v1.1:
addProduct(6790, 21972, CdcAcmSerialDriver::class.java)
}
}

View file

@ -1,38 +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.feature.connections.repository
/** USB serial connection. */
interface SerialConnection : AutoCloseable {
/** Called to initiate the serial connection. */
fun connect()
/**
* Send data (asynchronously) to the serial device. If the connection is not presently established then the data
* provided is ignored / dropped.
*/
fun sendBytes(bytes: ByteArray)
/**
* Close the USB serial connection.
*
* @param waitForStopped if true, waits for the connection to terminate before returning
*/
fun close(waitForStopped: Boolean)
override fun close()
}

View file

@ -1,118 +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.feature.connections.repository
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.util.SerialInputOutputManager
import org.meshtastic.core.common.util.ignoreException
import java.nio.BufferOverflowException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
internal class SerialConnectionImpl(
private val usbManagerLazy: Lazy<UsbManager?>,
private val device: UsbSerialDriver,
private val listener: SerialConnectionListener,
) : SerialConnection {
private val port = device.ports[0] // Most devices have just one port (port 0)
private val closedLatch = CountDownLatch(1)
private val closed = AtomicBoolean(false)
private val ioRef = AtomicReference<SerialInputOutputManager>()
@Suppress("TooGenericExceptionCaught")
override fun sendBytes(bytes: ByteArray) {
ioRef.get()?.let {
Logger.d { "writing ${bytes.size} byte(s)" }
try {
it.writeAsync(bytes)
} catch (e: BufferOverflowException) {
Logger.w(e) { "Buffer overflow while writing to serial port" }
} catch (e: Exception) {
// USB disconnections often cause IOExceptions here; log as warning to avoid Crashlytics noise
Logger.w(e) { "Failed to write to serial port (likely disconnected)" }
}
}
}
override fun close(waitForStopped: Boolean) {
if (closed.compareAndSet(false, true)) {
ignoreException(silent = true) { ioRef.get()?.stop() }
ignoreException(silent = true) {
port.close() // This will cause the reader thread to exit
}
}
// Allow a short amount of time for the manager to quit (so the port can be cleanly closed)
if (waitForStopped) {
Logger.d { "Waiting for USB manager to stop..." }
ignoreException(silent = true) { closedLatch.await(1, TimeUnit.SECONDS) }
}
}
override fun close() {
close(true)
}
override fun connect() {
// We shouldn't be able to get this far without a USB subsystem so explode if that isn't true
val usbManager = usbManagerLazy.value!!
val usbDeviceConnection = usbManager.openDevice(device.device)
if (usbDeviceConnection == null) {
listener.onMissingPermission()
closed.set(true)
return
}
port.open(usbDeviceConnection)
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
port.dtr = true
port.rts = true
Logger.d { "Starting serial reader thread" }
val io =
SerialInputOutputManager(
port,
object : SerialInputOutputManager.Listener {
override fun onNewData(data: ByteArray) {
listener.onDataReceived(data)
}
override fun onRunError(e: Exception?) {
closed.set(true)
// Connection is already failing, don't try to set DTR/RTS as it will just throw more
// IOExceptions
ignoreException(silent = true) { port.close() }
closedLatch.countDown()
listener.onDisconnected(e)
}
},
)
.apply {
readTimeout = 200 // To save battery we only timeout ever so often
ioRef.set(this)
}
io.start()
listener.onConnected()
}
}

View file

@ -1,32 +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.feature.connections.repository
/** Callbacks indicating state changes in the USB serial connection. */
interface SerialConnectionListener {
/** Unable to initiate the connection due to missing permissions. This is a terminal state. */
fun onMissingPermission() {}
/** Called when a connection has been established. */
fun onConnected() {}
/** Called when serial data is received. */
fun onDataReceived(bytes: ByteArray) {}
/** Called when the connection has been terminated. */
fun onDisconnected(thrown: Exception?) {}
}

View file

@ -1,60 +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.feature.connections.repository
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.exceptionReporter
import org.meshtastic.core.common.util.getParcelableExtraCompat
/** A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are changed. */
@Single
class UsbBroadcastReceiver(private val usbRepository: UsbRepository) : BroadcastReceiver() {
// Can be used for registering
internal val intentFilter
get() =
IntentFilter().apply {
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
}
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
val device: UsbDevice? = intent.getParcelableExtraCompat(UsbManager.EXTRA_DEVICE)
val deviceName: String = device?.deviceName ?: "unknown"
when (intent.action) {
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
Logger.d { "USB device '$deviceName' was detached" }
usbRepository.refreshState()
}
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device '$deviceName' was attached" }
usbRepository.refreshState()
}
UsbManager.EXTRA_PERMISSION_GRANTED -> {
Logger.d { "USB device '$deviceName' was granted permission" }
usbRepository.refreshState()
}
}
}
}

View file

@ -1,57 +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.feature.connections.repository
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.core.app.PendingIntentCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.common.util.registerReceiverCompat
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow<Boolean> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
trySend(granted)
close()
}
}
}
val permissionIntent =
PendingIntentCompat.getBroadcast(
context,
0,
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
0,
true,
)
val filter = IntentFilter(ACTION_USB_PERMISSION)
context.registerReceiverCompat(receiver, filter)
requestPermission(device, permissionIntent)
awaitClose { context.unregisterReceiver(receiver) }
}

View file

@ -1,88 +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.feature.connections.repository
import android.app.Application
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialProber
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.registerReceiverCompat
import org.meshtastic.core.di.CoroutineDispatchers
/** Repository responsible for maintaining and updating the state of USB connectivity. */
@OptIn(ExperimentalCoroutinesApi::class)
@Single
class UsbRepository(
private val application: Application,
private val dispatchers: CoroutineDispatchers,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
private val usbBroadcastReceiverLazy: Lazy<UsbBroadcastReceiver>,
private val usbManagerLazy: Lazy<UsbManager?>,
private val usbSerialProberLazy: Lazy<UsbSerialProber>,
) {
private val _serialDevices = MutableStateFlow(emptyMap<String, UsbDevice>())
val serialDevices =
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
buildMap {
serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
}
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
refreshStateInternal()
usbBroadcastReceiverLazy.value.let { receiver ->
application.registerReceiverCompat(receiver, receiver.intentFilter)
}
}
}
/**
* Creates a USB serial connection to the specified USB device. State changes and data arrival result in async
* callbacks on the supplied listener.
*/
fun createSerialConnection(device: UsbSerialDriver, listener: SerialConnectionListener): SerialConnection =
SerialConnectionImpl(usbManagerLazy, device, listener)
fun requestPermission(device: UsbDevice): Flow<Boolean> =
usbManagerLazy.value?.requestPermission(application, device) ?: emptyFlow()
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
private suspend fun refreshStateInternal() =
withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
}

View file

@ -1,22 +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.feature.connections.repository
object NetworkConstants {
const val SERVICE_PORT = 4403
const val SERVICE_TYPE = "_meshtastic._tcp"
}

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