Refactor and unify firmware update logic across platforms (#4966)

This commit is contained in:
James Rich 2026-04-01 07:14:26 -05:00 committed by GitHub
parent d8e295cafb
commit 89547afe6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 7206 additions and 3485 deletions

View file

@ -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 }
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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)) }
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 =

View file

@ -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

View file

@ -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 = {}

View file

@ -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