mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor and unify firmware update logic across platforms (#4966)
This commit is contained in:
parent
d8e295cafb
commit
89547afe6b
102 changed files with 7206 additions and 3485 deletions
|
|
@ -45,7 +45,10 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
|
|||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
|
||||
|
||||
/** ATT protocol header size (opcode + handle) subtracted from MTU to get the usable payload. */
|
||||
private const val ATT_HEADER_SIZE = 3
|
||||
|
||||
internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? {
|
||||
val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null
|
||||
return (mtu - 3).takeIf { it > 0 }
|
||||
return (mtu - ATT_HEADER_SIZE).takeIf { it > 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -28,6 +29,12 @@ enum class BleWriteType {
|
|||
WITHOUT_RESPONSE,
|
||||
}
|
||||
|
||||
/** Identifies a characteristic within a profiled BLE service. */
|
||||
data class BleCharacteristic(val uuid: Uuid)
|
||||
|
||||
/** Safe ATT payload length when MTU negotiation is unavailable (23-byte ATT MTU minus 3-byte header). */
|
||||
const val DEFAULT_BLE_WRITE_VALUE_LENGTH = 20
|
||||
|
||||
/** Encapsulates a BLE connection to a [BleDevice]. */
|
||||
interface BleConnection {
|
||||
/** The currently connected [BleDevice], or null if not connected. */
|
||||
|
|
@ -55,11 +62,27 @@ interface BleConnection {
|
|||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T
|
||||
|
||||
/** Returns the maximum write value length for the given write type. */
|
||||
/** Returns the maximum write value length for the given write type, or `null` if unknown. */
|
||||
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.
|
||||
/** Creates a handle for a characteristic belonging to this service. */
|
||||
fun characteristic(uuid: Uuid): BleCharacteristic = BleCharacteristic(uuid)
|
||||
|
||||
/** Returns true when the characteristic is present on the connected device. */
|
||||
fun hasCharacteristic(characteristic: BleCharacteristic): Boolean
|
||||
|
||||
/** Observes notifications/indications from the characteristic. */
|
||||
fun observe(characteristic: BleCharacteristic): Flow<ByteArray>
|
||||
|
||||
/** Reads the characteristic value once. */
|
||||
suspend fun read(characteristic: BleCharacteristic): ByteArray
|
||||
|
||||
/** Returns the preferred write type for the characteristic on this platform/device. */
|
||||
fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType
|
||||
|
||||
/** Writes a value to the characteristic using the requested BLE write type. */
|
||||
suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,4 @@
|
|||
package org.meshtastic.core.ble
|
||||
|
||||
/** Extension to convert a [BleService] to a [MeshtasticRadioProfile]. */
|
||||
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile {
|
||||
val kableService = this as KableBleService
|
||||
return KableMeshtasticRadioProfile(kableService.peripheral)
|
||||
}
|
||||
fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile = KableMeshtasticRadioProfile(this)
|
||||
|
|
|
|||
|
|
@ -16,13 +16,19 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.State
|
||||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
|
@ -30,13 +36,44 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableBleService(val peripheral: Peripheral) : BleService
|
||||
class KableBleService(private val peripheral: Peripheral, private val serviceUuid: Uuid) : BleService {
|
||||
override fun hasCharacteristic(characteristic: BleCharacteristic): Boolean = peripheral.services.value?.any { svc ->
|
||||
svc.serviceUuid == serviceUuid && svc.characteristics.any { it.characteristicUuid == characteristic.uuid }
|
||||
} == true
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
class KableBleConnection(private val scope: CoroutineScope, private val tag: String) : BleConnection {
|
||||
override fun observe(characteristic: BleCharacteristic) =
|
||||
peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid))
|
||||
|
||||
override suspend fun read(characteristic: BleCharacteristic): ByteArray =
|
||||
peripheral.read(characteristicOf(serviceUuid, characteristic.uuid))
|
||||
|
||||
override fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType {
|
||||
val service = peripheral.services.value?.find { it.serviceUuid == serviceUuid }
|
||||
val char = service?.characteristics?.find { it.characteristicUuid == characteristic.uuid }
|
||||
return if (char?.properties?.writeWithoutResponse == true) {
|
||||
BleWriteType.WITHOUT_RESPONSE
|
||||
} else {
|
||||
BleWriteType.WITH_RESPONSE
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
|
||||
peripheral.write(
|
||||
characteristicOf(serviceUuid, characteristic.uuid),
|
||||
data,
|
||||
when (writeType) {
|
||||
BleWriteType.WITH_RESPONSE -> WriteType.WithResponse
|
||||
BleWriteType.WITHOUT_RESPONSE -> WriteType.WithoutResponse
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
|
||||
|
||||
private var peripheral: Peripheral? = null
|
||||
private var stateJob: Job? = null
|
||||
|
|
@ -52,7 +89,7 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
MutableSharedFlow<BleConnectionState>(
|
||||
replay = 1,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
|
||||
|
||||
|
|
@ -64,14 +101,14 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
is KableBleDevice ->
|
||||
Peripheral(device.advertisement) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
is DirectBleDevice ->
|
||||
createPeripheral(device.address) {
|
||||
observationExceptionHandler { cause ->
|
||||
co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
|
||||
}
|
||||
platformConfig(device) { autoConnect.value }
|
||||
}
|
||||
|
|
@ -113,10 +150,10 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) {
|
||||
@Suppress("MagicNumber")
|
||||
val retryDelayMs = 1000L
|
||||
kotlinx.coroutines.delay(retryDelayMs)
|
||||
delay(retryDelayMs)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -124,17 +161,17 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) {
|
||||
withTimeout(timeoutMs) {
|
||||
connect(device)
|
||||
BleConnectionState.Connected
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
// Our own timeout expired — treat as a failed attempt so callers can retry.
|
||||
BleConnectionState.Disconnected
|
||||
} catch (e: CancellationException) {
|
||||
// External cancellation (scope closed) — must propagate.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
BleConnectionState.Disconnected
|
||||
}
|
||||
|
||||
|
|
@ -159,9 +196,9 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
|
|||
): T {
|
||||
val p = peripheral ?: error("Not connected")
|
||||
val cScope = connectionScope ?: error("No active connection scope")
|
||||
val service = KableBleService(p)
|
||||
val service = KableBleService(p, serviceUuid)
|
||||
return cScope.setup(service)
|
||||
}
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength() ?: 512
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@ import org.koin.core.annotation.Single
|
|||
|
||||
@Single
|
||||
class KableBleConnectionFactory : BleConnectionFactory {
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, tag)
|
||||
override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
|
||||
override val state: StateFlow<BleConnectionState> = _state
|
||||
|
||||
// Scanned devices can be connected directly without an explicit bonding step.
|
||||
// On Android, Kable's connectGatt triggers the OS pairing dialog transparently
|
||||
// when the firmware requires an encrypted link. On Desktop, btleplug delegates
|
||||
// to the OS Bluetooth stack which handles pairing the same way.
|
||||
// The BleRadioInterface.connect() reconnection path has a separate isBonded
|
||||
// check for the case where a previously bonded device loses its bond.
|
||||
// Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly.
|
||||
override val isBonded: Boolean = true
|
||||
|
||||
override val isConnected: Boolean
|
||||
|
|
@ -52,8 +47,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
|
|||
}
|
||||
|
||||
override suspend fun bond() {
|
||||
// Bonding for scanned devices is handled at the BluetoothRepository level
|
||||
// (Android) or by the OS during GATT connection (Desktop/JVM).
|
||||
// No-op: bonding is OS-managed on Android and not required on desktop.
|
||||
}
|
||||
|
||||
internal fun updateState(newState: BleConnectionState) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ package org.meshtastic.core.ble
|
|||
|
||||
import com.juul.kable.Scanner
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.time.Duration
|
||||
import kotlin.uuid.Uuid
|
||||
|
|
@ -26,29 +28,21 @@ import kotlin.uuid.Uuid
|
|||
class KableBleScanner : BleScanner {
|
||||
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> {
|
||||
val scanner = Scanner {
|
||||
// When both serviceUuid and address are provided (the findDevice reconnect path),
|
||||
// filter by service UUID only. The caller applies address filtering post-collection.
|
||||
// Using a single match{} with both creates an AND filter that silently drops results
|
||||
// on some OEM BLE stacks (Samsung, Xiaomi) when the device uses a random resolvable
|
||||
// private address. Using separate match{} blocks creates OR semantics which would
|
||||
// return all Meshtastic devices, so we only filter by service UUID in that case.
|
||||
if (serviceUuid != null || address != null) {
|
||||
filters {
|
||||
if (serviceUuid != null) {
|
||||
match { services = listOf(serviceUuid) }
|
||||
} else if (address != null) {
|
||||
// Address-only scan (no service UUID filter). BLE MAC addresses are
|
||||
// normalized to uppercase on Android; uppercase() covers any edge cases.
|
||||
match { this.address = address.uppercase() }
|
||||
}
|
||||
}
|
||||
// Use separate match blocks so each filter is evaluated independently (OR semantics).
|
||||
// Combining address and service UUID in a single match{} creates an AND filter which
|
||||
// silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a
|
||||
// random resolvable private address.
|
||||
if (address != null) {
|
||||
filters { match { this.address = address } }
|
||||
} else if (serviceUuid != null) {
|
||||
filters { match { services = listOf(serviceUuid) } }
|
||||
}
|
||||
}
|
||||
|
||||
// Kable's Scanner doesn't enforce timeout internally, it runs until the Flow is cancelled.
|
||||
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
|
||||
return kotlinx.coroutines.flow.channelFlow {
|
||||
kotlinx.coroutines.withTimeoutOrNull(timeout) {
|
||||
return channelFlow {
|
||||
withTimeoutOrNull(timeout) {
|
||||
scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,6 @@
|
|||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.WriteType
|
||||
import com.juul.kable.characteristicOf
|
||||
import com.juul.kable.writeWithoutResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -31,17 +26,15 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
|
|||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : MeshtasticRadioProfile {
|
||||
class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile {
|
||||
|
||||
private val toRadio = characteristicOf(SERVICE_UUID, TORADIO_CHARACTERISTIC)
|
||||
private val fromRadioChar = characteristicOf(SERVICE_UUID, FROMRADIO_CHARACTERISTIC)
|
||||
private val fromRadioSync = characteristicOf(SERVICE_UUID, FROMRADIOSYNC_CHARACTERISTIC)
|
||||
private val fromNum = characteristicOf(SERVICE_UUID, FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = characteristicOf(SERVICE_UUID, LOGRADIO_CHARACTERISTIC)
|
||||
private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC)
|
||||
private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC)
|
||||
private val fromRadioSync = service.characteristic(FROMRADIOSYNC_CHARACTERISTIC)
|
||||
private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC)
|
||||
private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC)
|
||||
|
||||
// replay = 1: a seed emission placed here before the collector starts is replayed to the
|
||||
// collector immediately on subscription. This is what drives the initial FROMRADIO poll
|
||||
|
|
@ -51,19 +44,6 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
private val triggerDrain =
|
||||
MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
init {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
Logger.i {
|
||||
"KableMeshtasticRadioProfile init. Discovered characteristics: ${svc?.characteristics?.map {
|
||||
it.characteristicUuid
|
||||
}}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCharacteristic(uuid: Uuid): Boolean = peripheral.services.value?.any { svc ->
|
||||
svc.serviceUuid == SERVICE_UUID && svc.characteristics.any { it.characteristicUuid == uuid }
|
||||
} == true
|
||||
|
||||
// Using observe() for fromRadioSync or legacy read loop for fromRadio
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val fromRadio: Flow<ByteArray> = channelFlow {
|
||||
|
|
@ -71,19 +51,19 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
// This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
|
||||
launch {
|
||||
try {
|
||||
if (hasCharacteristic(FROMRADIOSYNC_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromRadioSync).collect { send(it) }
|
||||
if (service.hasCharacteristic(fromRadioSync)) {
|
||||
service.observe(fromRadioSync).collect { send(it) }
|
||||
} else {
|
||||
error("fromRadioSync missing")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Fallback to legacy FROMNUM/FROMRADIO polling.
|
||||
// Wire up FROMNUM notifications for steady-state packet delivery.
|
||||
launch {
|
||||
if (hasCharacteristic(FROMNUM_CHARACTERISTIC)) {
|
||||
peripheral.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
if (service.hasCharacteristic(fromNum)) {
|
||||
service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
|
||||
}
|
||||
}
|
||||
// Seed the replay buffer so the collector below starts draining immediately.
|
||||
|
|
@ -95,13 +75,13 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
var keepReading = true
|
||||
while (keepReading) {
|
||||
try {
|
||||
if (!hasCharacteristic(FROMRADIO_CHARACTERISTIC)) {
|
||||
if (!service.hasCharacteristic(fromRadioChar)) {
|
||||
keepReading = false
|
||||
continue
|
||||
}
|
||||
val packet = peripheral.read(fromRadioChar)
|
||||
val packet = service.read(fromRadioChar)
|
||||
if (packet.isEmpty()) keepReading = false else send(packet)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
keepReading = false
|
||||
}
|
||||
}
|
||||
|
|
@ -113,27 +93,16 @@ class KableMeshtasticRadioProfile(private val peripheral: Peripheral) : Meshtast
|
|||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override val logRadio: Flow<ByteArray> = channelFlow {
|
||||
try {
|
||||
if (hasCharacteristic(LOGRADIO_CHARACTERISTIC)) {
|
||||
peripheral.observe(logRadioChar).collect { send(it) }
|
||||
if (service.hasCharacteristic(logRadioChar)) {
|
||||
service.observe(logRadioChar).collect { send(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// logRadio is optional, ignore if not found
|
||||
}
|
||||
}
|
||||
|
||||
private val toRadioWriteType: WriteType by lazy {
|
||||
val svc = peripheral.services.value?.find { it.serviceUuid == SERVICE_UUID }
|
||||
val char = svc?.characteristics?.find { it.characteristicUuid == TORADIO_CHARACTERISTIC }
|
||||
|
||||
if (char?.properties?.writeWithoutResponse == true) {
|
||||
WriteType.WithoutResponse
|
||||
} else {
|
||||
WriteType.WithResponse
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendToRadio(packet: ByteArray) {
|
||||
peripheral.write(toRadio, packet, toRadioWriteType)
|
||||
service.write(toRadio, packet, service.preferredWriteType(toRadio))
|
||||
triggerDrain.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,16 @@
|
|||
package org.meshtastic.core.testing
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.meshtastic.core.ble.BleCharacteristic
|
||||
import org.meshtastic.core.ble.BleConnection
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
|
|
@ -100,6 +103,14 @@ class FakeBleConnection :
|
|||
/** When non-null, [connectAndAwait] throws this exception instead of connecting. */
|
||||
var connectException: Exception? = null
|
||||
|
||||
/** Negotiated write length exposed to callers; `null` means unknown / not negotiated. */
|
||||
var maxWriteValueLength: Int? = null
|
||||
|
||||
/** Number of times [disconnect] has been invoked. */
|
||||
var disconnectCalls: Int = 0
|
||||
|
||||
val service = FakeBleService()
|
||||
|
||||
override suspend fun connect(device: BleDevice) {
|
||||
_device.value = device
|
||||
_deviceFlow.emit(device)
|
||||
|
|
@ -124,6 +135,7 @@ class FakeBleConnection :
|
|||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
disconnectCalls++
|
||||
val currentDevice = _device.value
|
||||
_connectionState.emit(BleConnectionState.Disconnected)
|
||||
if (currentDevice is FakeBleDevice) {
|
||||
|
|
@ -137,12 +149,58 @@ class FakeBleConnection :
|
|||
serviceUuid: Uuid,
|
||||
timeout: Duration,
|
||||
setup: suspend CoroutineScope.(BleService) -> T,
|
||||
): T = CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined).setup(FakeBleService())
|
||||
): T = CoroutineScope(Dispatchers.Unconfined).setup(service)
|
||||
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int = 512
|
||||
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = maxWriteValueLength
|
||||
}
|
||||
|
||||
class FakeBleService : BleService
|
||||
class FakeBleWrite(val characteristic: BleCharacteristic, val data: ByteArray, val writeType: BleWriteType) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is FakeBleWrite) return false
|
||||
return characteristic == other.characteristic && data.contentEquals(other.data) && writeType == other.writeType
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * (31 * characteristic.hashCode() + data.contentHashCode()) + writeType.hashCode()
|
||||
}
|
||||
|
||||
class FakeBleService : BleService {
|
||||
private val availableCharacteristics = mutableSetOf<Uuid>()
|
||||
private val notificationFlows = mutableMapOf<Uuid, MutableSharedFlow<ByteArray>>()
|
||||
private val readQueues = mutableMapOf<Uuid, MutableList<ByteArray>>()
|
||||
|
||||
val writes = mutableListOf<FakeBleWrite>()
|
||||
|
||||
override fun hasCharacteristic(characteristic: BleCharacteristic): Boolean =
|
||||
availableCharacteristics.contains(characteristic.uuid)
|
||||
|
||||
override fun observe(characteristic: BleCharacteristic): Flow<ByteArray> =
|
||||
notificationFlows.getOrPut(characteristic.uuid) { MutableSharedFlow(extraBufferCapacity = 16) }
|
||||
|
||||
override suspend fun read(characteristic: BleCharacteristic): ByteArray =
|
||||
readQueues[characteristic.uuid]?.removeFirstOrNull() ?: ByteArray(0)
|
||||
|
||||
override fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType = BleWriteType.WITH_RESPONSE
|
||||
|
||||
override suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
|
||||
availableCharacteristics += characteristic.uuid
|
||||
writes += FakeBleWrite(characteristic = characteristic, data = data.copyOf(), writeType = writeType)
|
||||
}
|
||||
|
||||
fun addCharacteristic(uuid: Uuid) {
|
||||
availableCharacteristics += uuid
|
||||
}
|
||||
|
||||
fun emitNotification(uuid: Uuid, data: ByteArray) {
|
||||
availableCharacteristics += uuid
|
||||
notificationFlows.getOrPut(uuid) { MutableSharedFlow(extraBufferCapacity = 16) }.tryEmit(data)
|
||||
}
|
||||
|
||||
fun enqueueRead(uuid: Uuid, data: ByteArray) {
|
||||
availableCharacteristics += uuid
|
||||
readQueues.getOrPut(uuid) { mutableListOf() }.add(data)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBleConnectionFactory(private val fakeConnection: FakeBleConnection = FakeBleConnection()) :
|
||||
BleConnectionFactory {
|
||||
|
|
@ -160,8 +218,7 @@ class FakeBluetoothRepository :
|
|||
|
||||
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotBlank()
|
||||
|
||||
override fun isBonded(address: String): Boolean =
|
||||
_state.value.bondedDevices.any { it.address.equals(address, ignoreCase = true) }
|
||||
override fun isBonded(address: String): Boolean = _state.value.bondedDevices.any { it.address == address }
|
||||
|
||||
override suspend fun bond(device: BleDevice) {
|
||||
val currentState = _state.value
|
||||
|
|
|
|||
|
|
@ -14,18 +14,30 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.net.toUri
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import java.net.URLEncoder
|
||||
|
||||
@Composable
|
||||
|
|
@ -116,6 +128,61 @@ actual fun rememberSaveFileLauncher(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit {
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
onUriReceived(uri?.let { CommonUri(it) })
|
||||
}
|
||||
return remember(launcher) { { mimeType -> launcher.launch(mimeType) } }
|
||||
}
|
||||
|
||||
@Suppress("Wrapping")
|
||||
@Composable
|
||||
actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? {
|
||||
val context = LocalContext.current
|
||||
return remember(context) {
|
||||
{ uri, maxChars ->
|
||||
withContext(Dispatchers.IO) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val androidUri = Uri.parse(uri.toString())
|
||||
context.contentResolver.openInputStream(androidUri)?.use { stream ->
|
||||
stream.bufferedReader().use { reader ->
|
||||
val buffer = CharArray(maxChars)
|
||||
val read = reader.read(buffer)
|
||||
if (read > 0) String(buffer, 0, read) else null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to read text from URI: $uri" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun KeepScreenOn(enabled: Boolean) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(enabled) {
|
||||
if (enabled) {
|
||||
view.keepScreenOn = true
|
||||
}
|
||||
onDispose {
|
||||
if (enabled) {
|
||||
view.keepScreenOn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
|
||||
BackHandler(enabled = enabled, onBack = onBack)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
|
||||
val launcher =
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
|
||||
/** Returns a function to open the platform's NFC settings. */
|
||||
@Composable expect fun rememberOpenNfcSettings(): () -> Unit
|
||||
|
|
@ -37,9 +41,24 @@ import org.jetbrains.compose.resources.StringResource
|
|||
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
|
||||
@Composable
|
||||
expect fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
onUriReceived: (MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit
|
||||
|
||||
/** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */
|
||||
@Composable expect fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit
|
||||
|
||||
/**
|
||||
* Returns a suspend function that reads up to [maxChars] characters of text from a [CommonUri]. Returns `null` if the
|
||||
* file is empty or cannot be read.
|
||||
*/
|
||||
@Composable expect fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String?
|
||||
|
||||
/** Keeps the screen awake while [enabled] is true. No-op on platforms that don't support it. */
|
||||
@Composable expect fun KeepScreenOn(enabled: Boolean)
|
||||
|
||||
/** Intercepts the platform back gesture/button while [enabled] is true. No-op on platforms without a system back. */
|
||||
@Composable expect fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit)
|
||||
|
||||
/** Returns a launcher to request location permissions. */
|
||||
@Composable expect fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import androidx.compose.ui.platform.ClipEntry
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
|
||||
actual fun createClipEntry(text: String, label: String): ClipEntry =
|
||||
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
|
||||
|
|
@ -39,9 +41,18 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
|
|||
|
||||
@Composable
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
onUriReceived: (MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
|
||||
|
||||
@Composable
|
||||
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> }
|
||||
|
||||
@Composable actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { _, _ -> null }
|
||||
|
||||
@Composable actual fun KeepScreenOn(enabled: Boolean) {}
|
||||
|
||||
@Composable actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {}
|
||||
|
||||
@Composable actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
|
||||
|
||||
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,22 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import java.awt.Desktop
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
/** JVM stub — NFC settings are not available on Desktop. */
|
||||
@Composable
|
||||
|
|
@ -47,12 +58,68 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
|
|||
}
|
||||
}
|
||||
|
||||
/** JVM stub — Save file launcher is a no-op on desktop until implemented. */
|
||||
/** JVM — Opens a native file dialog to save a file. */
|
||||
@Composable
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ ->
|
||||
Logger.w { "File saving not implemented on Desktop" }
|
||||
onUriReceived: (MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ ->
|
||||
val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE)
|
||||
dialog.file = defaultFilename
|
||||
dialog.isVisible = true
|
||||
val file = dialog.file
|
||||
val dir = dialog.directory
|
||||
if (file != null && dir != null) {
|
||||
val path = File(dir, file)
|
||||
onUriReceived(MeshtasticUri(path.toURI().toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM — Opens a native file dialog to pick a file. */
|
||||
@Composable
|
||||
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ ->
|
||||
val dialog = FileDialog(null as? Frame, "Open File", FileDialog.LOAD)
|
||||
dialog.isVisible = true
|
||||
val file = dialog.file
|
||||
val dir = dialog.directory
|
||||
if (file != null && dir != null) {
|
||||
val path = File(dir, file)
|
||||
onUriReceived(CommonUri(path.toURI()))
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM — Reads text from a file URI. */
|
||||
@Composable
|
||||
actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { uri, maxChars ->
|
||||
withContext(Dispatchers.IO) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
val file = File(URI(uri.toString()))
|
||||
if (file.exists()) {
|
||||
file.bufferedReader().use { reader ->
|
||||
val buffer = CharArray(maxChars)
|
||||
val read = reader.read(buffer)
|
||||
if (read > 0) String(buffer, 0, read) else null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(e) { "Failed to read text from URI: $uri" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM no-op — Keep screen on is not applicable on Desktop. */
|
||||
@Composable
|
||||
actual fun KeepScreenOn(enabled: Boolean) {
|
||||
// No-op on JVM/Desktop
|
||||
}
|
||||
|
||||
/** JVM no-op — Desktop has no system back gesture. */
|
||||
@Composable
|
||||
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
|
||||
// No-op on JVM/Desktop — no system back button
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue