chore: KMP audit — commonize code, centralize utilities, eliminate dead abstractions (#5133)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-14 21:17:50 -05:00 committed by GitHub
parent 50ade01e55
commit 72b981f73b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 2186 additions and 916 deletions

View file

@ -39,6 +39,8 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import org.jetbrains.compose.resources.stringResource
@ -75,8 +77,11 @@ fun CurrentlyConnectedInfo(
while (bleDevice.device.isConnected) {
try {
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
} catch (_: TimeoutCancellationException) {
Logger.d { "RSSI read timed out" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// RSSI reading failures (or timeouts) are common; log as debug to avoid Crashlytics noise
Logger.d(e) { "Failed to read RSSI ${e.message}" }
}
delay(RSSI_DELAY.seconds)

View file

@ -18,6 +18,7 @@ package org.meshtastic.feature.firmware
import android.content.Context
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.head
@ -32,7 +33,6 @@ import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileOutputStream
@ -188,7 +188,7 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
if (!tempDir.exists()) tempDir.mkdirs()
try {
val platformUri = uri.toPlatformUri() as android.net.Uri
val platformUri = uri.toAndroidUri()
val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null
ZipInputStream(inputStream).use { zipInput ->
var entry = zipInput.nextEntry
@ -225,9 +225,9 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) {
file.toLocalFileOrNull()?.takeIf { it.exists() }?.length()
?: context.contentResolver
.openAssetFileDescriptor(file.uri.toPlatformUri() as android.net.Uri, "r")
?.use { descriptor -> descriptor.length.takeIf { it >= 0L } }
?: context.contentResolver.openAssetFileDescriptor(file.uri.toAndroidUri(), "r")?.use { descriptor ->
descriptor.length.takeIf { it >= 0L }
}
?: 0L
}
@ -242,16 +242,13 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
if (localFile != null && localFile.exists()) {
localFile.readBytes()
} else {
context.contentResolver.openInputStream(artifact.uri.toPlatformUri() as android.net.Uri)?.use {
it.readBytes()
} ?: throw IOException("Cannot open artifact: ${artifact.uri}")
context.contentResolver.openInputStream(artifact.uri.toAndroidUri())?.use { it.readBytes() }
?: throw IOException("Cannot open artifact: ${artifact.uri}")
}
}
override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) {
val inputStream =
context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri)
?: return@withContext null
val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null
val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin")
tempFile.parentFile?.mkdirs()
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
@ -282,10 +279,10 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
withContext(ioDispatcher) {
val inputStream =
source.toLocalFileOrNull()?.inputStream()
?: context.contentResolver.openInputStream(source.uri.toPlatformUri() as android.net.Uri)
?: context.contentResolver.openInputStream(source.uri.toAndroidUri())
?: throw IOException("Cannot open source URI")
val outputStream =
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
context.contentResolver.openOutputStream(destinationUri.toAndroidUri())
?: throw IOException("Cannot open content URI for writing")
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }

View file

@ -163,9 +163,7 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
uri?.let { viewModel.startUpdateFromFile(it) }
}
val saveFileLauncher = rememberSaveFileLauncher { meshtasticUri ->
viewModel.saveDfuFile(CommonUri.parse(meshtasticUri.uriString))
}
val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) }
val actions =
remember(viewModel, onNavigateUp) {

View file

@ -36,6 +36,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.datastore.BootloaderWarningDataSource
@ -123,9 +124,12 @@ class FirmwareUpdateViewModel(
override fun onCleared() {
super.onCleared()
// viewModelScope is already cancelled when onCleared() runs, so use a standalone scope
// for fire-and-forget cleanup of temporary firmware files.
kotlinx.coroutines.CoroutineScope(NonCancellable).launch {
// viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
// standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
// shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
// is cancelled concurrently.
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
@ -147,7 +151,7 @@ class FirmwareUpdateViewModel(
updateJob =
viewModelScope.launch {
_state.value = FirmwareUpdateState.Checking
runCatching {
safeCatching {
val ourNode = nodeRepository.myNodeInfo.value
val address = radioPrefs.devAddr.value?.drop(1)
if (address == null || ourNode == null) {
@ -200,7 +204,6 @@ class FirmwareUpdateViewModel(
}
}
.onFailure { e ->
if (e is CancellationException) throw e
Logger.e(e) { "Error checking for updates" }
val unknownError = UiText.Resource(Res.string.firmware_update_unknown_error)
_state.value =
@ -390,7 +393,7 @@ private suspend fun cleanupTemporaryFiles(
fileHandler: FirmwareFileHandler,
tempFirmwareFile: FirmwareArtifact?,
): FirmwareArtifact? {
runCatching {
safeCatching {
tempFirmwareFile?.takeIf { it.isTemporary }?.let { fileHandler.deleteFile(it) }
fileHandler.cleanupAllTemporaryFiles()
}

View file

@ -38,6 +38,7 @@ import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
import org.meshtastic.core.common.util.safeCatching
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ -78,7 +79,7 @@ class BleOtaTransport(
}
@Suppress("MagicNumber")
override suspend fun connect(): Result<Unit> = runCatching {
override suspend fun connect(): Result<Unit> = safeCatching {
Logger.i { "BLE OTA: Waiting $REBOOT_DELAY for device to reboot into OTA mode..." }
delay(REBOOT_DELAY)
@ -152,7 +153,7 @@ class BleOtaTransport(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result<Unit> = runCatching {
): Result<Unit> = safeCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
val packetsSent = sendCommand(command)
@ -189,7 +190,7 @@ class BleOtaTransport(
data: ByteArray,
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
): Result<Unit> = runCatching {
): Result<Unit> = safeCatching {
val totalBytes = data.size
var sentBytes = 0
@ -215,7 +216,7 @@ class BleOtaTransport(
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
sentBytes = nextSentBytes
onProgress(1.0f)
return@runCatching Unit
return@safeCatching Unit
}
}
is OtaResponse.Error -> {

View file

@ -45,7 +45,7 @@ internal fun calculateMacPlusOne(macAddress: String): String {
if (parts.size != MAC_PARTS_COUNT) return macAddress
val lastByte = parts[MAC_PARTS_COUNT - 1].toIntOrNull(HEX_RADIX) ?: return macAddress
val incremented = ((lastByte + 1) and BYTE_MASK).toString(HEX_RADIX).uppercase().padStart(2, '0')
return parts.take(MAC_PARTS_COUNT - 1).joinToString(":") + ":" + incremented
return "${parts.take(MAC_PARTS_COUNT - 1).joinToString(":")}:$incremented"
}
/**

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.safeCatching
/**
* WiFi/TCP transport implementation for ESP32 Unified OTA protocol.
@ -54,7 +55,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
/** Connect to the device via TCP using Ktor raw sockets. */
override suspend fun connect(): Result<Unit> = withContext(ioDispatcher) {
runCatching {
safeCatching {
Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" }
val selector = SelectorManager(ioDispatcher)
@ -82,7 +83,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result<Unit> = runCatching {
): Result<Unit> = safeCatching {
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
sendCommand(command)
@ -116,7 +117,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
): Result<Unit> = withContext(ioDispatcher) {
runCatching {
safeCatching {
if (!isConnected) {
throw OtaProtocolException.TransferFailed("Not connected")
}

View file

@ -46,6 +46,7 @@ import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
import org.meshtastic.feature.firmware.ota.scanForBleDevice
import kotlin.time.Duration
@ -91,7 +92,7 @@ class SecureDfuTransport(
*
* The caller must have already released the mesh-service BLE connection before calling this.
*/
suspend fun triggerButtonlessDfu(): Result<Unit> = runCatching {
suspend fun triggerButtonlessDfu(): Result<Unit> = safeCatching {
Logger.i { "DFU: Scanning for device $address to trigger buttonless DFU..." }
val device =
@ -152,7 +153,7 @@ class SecureDfuTransport(
* Scans for the device in DFU mode (address or address+1) and establishes the GATT connection, enabling
* notifications on the Control Point.
*/
suspend fun connectToDfuMode(): Result<Unit> = runCatching {
suspend fun connectToDfuMode(): Result<Unit> = safeCatching {
val dfuAddress = calculateMacPlusOne(address)
val targetAddresses = setOf(address, dfuAddress)
Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." }
@ -210,7 +211,7 @@ class SecureDfuTransport(
* PRN is explicitly disabled (set to 0) for the init packet per the Nordic DFU library convention the init packet
* is small (<512 bytes, fits in a single object) and does not benefit from flow control.
*/
suspend fun transferInitPacket(initPacket: ByteArray): Result<Unit> = runCatching {
suspend fun transferInitPacket(initPacket: ByteArray): Result<Unit> = safeCatching {
Logger.i { "DFU: Transferring init packet (${initPacket.size} bytes)..." }
setPrn(0)
transferObjectWithRetry(DfuObjectType.COMMAND, initPacket, onProgress = null)
@ -231,12 +232,13 @@ class SecureDfuTransport(
* @param firmware Raw bytes of the `.bin` file.
* @param onProgress Callback receiving progress in [0.0, 1.0].
*/
suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit> = runCatching {
Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." }
setPrn(PRN_INTERVAL)
transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress)
Logger.i { "DFU: Firmware transferred and executed." }
}
suspend fun transferFirmware(firmware: ByteArray, onProgress: suspend (Float) -> Unit): Result<Unit> =
safeCatching {
Logger.i { "DFU: Transferring firmware (${firmware.size} bytes)..." }
setPrn(PRN_INTERVAL)
transferObjectWithRetry(DfuObjectType.DATA, firmware, onProgress)
Logger.i { "DFU: Firmware transferred and executed." }
}
// ---------------------------------------------------------------------------
// Abort & teardown

View file

@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.ChannelsRoute
import org.meshtastic.core.navigation.ContactsRoute
import org.meshtastic.core.navigation.NodesRoute
@ -35,7 +35,7 @@ fun AdaptiveContactsScreen(
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
) {

View file

@ -44,6 +44,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -61,7 +62,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
@ -117,7 +118,7 @@ fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onHandleDeepLink: (CommonUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
@ -131,8 +132,8 @@ fun ContactsScreen(
val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
var showMuteDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showMuteDialog by rememberSaveable { mutableStateOf(false) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// State for managing selected contacts
val selectedContactKeys = remember { mutableStateListOf<String>() }
@ -255,7 +256,7 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
onHandleDeepLink(MeshtasticUri(uriString)) {
onHandleDeepLink(CommonUri.parse(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},

View file

@ -49,7 +49,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
@ -263,7 +263,7 @@ private fun SignalRow(node: Node) {
if (node.snr != Float.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.snr),
value = formatString("%.1f dB", node.snr),
value = MetricFormatter.snr(node.snr),
icon = MeshtasticIcons.Snr,
modifier = Modifier.weight(1f),
)
@ -273,7 +273,7 @@ private fun SignalRow(node: Node) {
if (node.rssi != Int.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.rssi),
value = formatString("%d dBm", node.rssi),
value = MetricFormatter.rssi(node.rssi),
icon = MeshtasticIcons.Rssi,
modifier = Modifier.weight(1f),
)

View file

@ -46,12 +46,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
@ -260,14 +259,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
icon = MeshtasticIcons.ChannelUtilization,
contentDescription = stringResource(Res.string.channel_utilization),
label = stringResource(Res.string.channel_utilization),
text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization),
text = MetricFormatter.percent(thatNode.deviceMetrics.channel_utilization ?: 0f),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
label = stringResource(Res.string.air_utilization),
text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx),
text = MetricFormatter.percent(thatNode.deviceMetrics.air_util_tx ?: 0f),
contentColor = contentColor,
)
}
@ -320,31 +319,24 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
}
if ((env.temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f))
} else {
formatString("%.1f°C", env.temperature ?: 0f)
}
val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.relative_humidity ?: 0f) != 0f) {
items.add {
HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor)
HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)
}
}
if ((env.barometric_pressure ?: 0f) != 0f) {
items.add {
PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor)
PressureInfo(
pressure = MetricFormatter.pressure(env.barometric_pressure ?: 0f),
contentColor = contentColor,
)
}
}
if ((env.soil_temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f))
} else {
formatString("%.1f°C", env.soil_temperature ?: 0f)
}
val temp = MetricFormatter.temperature(env.soil_temperature ?: 0f, tempInFahrenheit)
items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) {
@ -353,7 +345,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.voltage ?: 0f) != 0f) {
items.add {
PowerInfo(
value = formatString("%.2fV", env.voltage ?: 0f),
value = MetricFormatter.voltage(env.voltage ?: 0f),
label = stringResource(Res.string.voltage),
contentColor = contentColor,
)
@ -362,7 +354,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.current ?: 0f) != 0f) {
items.add {
PowerInfo(
value = formatString("%.1fmA", env.current ?: 0f),
value = MetricFormatter.current(env.current ?: 0f),
label = stringResource(Res.string.current),
contentColor = contentColor,
)

View file

@ -72,7 +72,7 @@ fun NodeListScreen(
onNavigateToChannels: () -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@ -125,7 +125,7 @@ fun NodeListScreen(
alignment = androidx.compose.ui.Alignment.BottomEnd,
),
onImport = { uriString ->
onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) {
onHandleDeepLink(org.meshtastic.core.common.util.CommonUri.parse(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},

View file

@ -55,6 +55,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
@ -230,12 +232,13 @@ private fun DeviceMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
val formatted = NumberFormatter.format(value, 1)
when (color) {
batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value)
else -> formatString(numericValueTemplate, value)
batteryColor -> formatString(percentValueTemplate, batteryLabel, formatted)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, formatted)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, formatted)
airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, formatted)
else -> formatString(numericValueTemplate, formatted)
}
},
)
@ -337,7 +340,7 @@ private fun DeviceMetricsChart(
if (leftLayer != null) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = batteryColor),
valueFormatter = { _, value, _ -> formatString("%.0f%%", value) },
valueFormatter = { _, value, _ -> MetricFormatter.percent(value.toFloat(), 0) },
)
} else {
null
@ -346,7 +349,7 @@ private fun DeviceMetricsChart(
if (rightLayer != null) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" },
)
} else {
null
@ -441,7 +444,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
formatString(
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
NumberFormatter.format(deviceMetrics.channel_utilization ?: 0f, 1),
),
)
Spacer(Modifier.width(12.dp))
@ -453,7 +456,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
NumberFormatter.format(deviceMetrics.air_util_tx ?: 0f, 1),
),
)
}

View file

@ -37,7 +37,7 @@ import okio.ByteString.Companion.decodeBase64
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
@ -333,7 +333,7 @@ open class MetricsViewModel(
* epoch-seconds timestamp extracted by [epochSeconds].
*/
private fun <T> exportCsv(
uri: MeshtasticUri,
uri: CommonUri,
header: String,
rows: List<T>,
epochSeconds: (T) -> Long,
@ -351,11 +351,10 @@ open class MetricsViewModel(
}
}
fun savePositionCSV(uri: MeshtasticUri, data: List<org.meshtastic.proto.Position>) {
fun savePositionCSV(uri: CommonUri, data: List<org.meshtastic.proto.Position>) {
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\"," + "\"satsInView\",\"speed\",\"heading\"\n",
header = "\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { pos ->
@ -366,7 +365,7 @@ open class MetricsViewModel(
}
}
fun saveDeviceMetricsCSV(uri: MeshtasticUri, data: List<Telemetry>) {
fun saveDeviceMetricsCSV(uri: CommonUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
header =
@ -382,7 +381,7 @@ open class MetricsViewModel(
}
}
fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List<Telemetry>) {
fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List<Telemetry>) {
val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" }
exportCsv(
uri = uri,
@ -405,7 +404,7 @@ open class MetricsViewModel(
}
}
fun saveSignalMetricsCSV(uri: MeshtasticUri, data: List<MeshPacket>) {
fun saveSignalMetricsCSV(uri: CommonUri, data: List<MeshPacket>) {
exportCsv(
uri = uri,
header = "\"date\",\"time\",\"rssi\",\"snr\"\n",
@ -416,7 +415,7 @@ open class MetricsViewModel(
}
}
fun savePowerMetricsCSV(uri: MeshtasticUri, data: List<Telemetry>) {
fun savePowerMetricsCSV(uri: CommonUri, data: List<Telemetry>) {
exportCsv(
uri = uri,
header =

View file

@ -54,7 +54,8 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
@ -194,9 +195,9 @@ private fun PowerMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color) {
currentColor -> formatString("Current: %.0f mA", value)
voltageColor -> formatString("Voltage: %.1f V", value)
else -> formatString("%.1f", value)
currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}"
voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V"
else -> NumberFormatter.format(value.toFloat(), 1)
}
},
)
@ -256,7 +257,7 @@ private fun PowerMetricsChart(
if (currentData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
valueFormatter = { _, value, _ -> formatString("%.0f mA", value) },
valueFormatter = { _, value, _ -> MetricFormatter.current(value.toFloat(), 0) },
)
} else {
null
@ -265,7 +266,7 @@ private fun PowerMetricsChart(
if (voltageData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" },
)
} else {
null
@ -369,8 +370,8 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage))
MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current))
MetricValueRow(color = PowerMetric.VOLTAGE.color, text = MetricFormatter.voltage(voltage))
MetricValueRow(color = PowerMetric.CURRENT.color, text = MetricFormatter.current(current))
}
}

View file

@ -47,7 +47,7 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
@ -157,9 +157,9 @@ private fun SignalMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color == rssiColor) {
formatString("RSSI: %.0f dBm", value)
"RSSI: ${MetricFormatter.rssi(value.toInt())}"
} else {
formatString("SNR: %.1f dB", value)
"SNR: ${MetricFormatter.snr(value.toFloat())}"
}
},
)
@ -189,7 +189,7 @@ private fun SignalMetricsChart(
if (rssiData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) },
valueFormatter = { _, value, _ -> MetricFormatter.rssi(value.toInt()) },
)
} else {
null
@ -198,7 +198,7 @@ private fun SignalMetricsChart(
if (snrData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
valueFormatter = { _, value, _ -> formatString("%.1f dB", value) },
valueFormatter = { _, value, _ -> MetricFormatter.snr(value.toFloat()) },
)
} else {
null
@ -234,15 +234,9 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* SNR and RSSI */
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(
color = SignalMetric.RSSI.color,
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
)
MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi))
Spacer(Modifier.width(12.dp))
MetricValueRow(
color = SignalMetric.SNR.color,
text = formatString("%.1f dB", meshPacket.rx_snr),
)
MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr))
}
}
}

View file

@ -56,6 +56,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
@ -113,7 +114,7 @@ fun TracerouteLogScreen(
val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest)
val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us)
val durationTemplate = stringResource(Res.string.traceroute_duration, "%SECS%")
val durationFormatStr = stringResource(Res.string.traceroute_duration)
val threshold = timeFrame.timeThreshold()
val filteredRequests =
@ -176,7 +177,7 @@ fun TracerouteLogScreen(
getUsername = ::getUsername,
headerTowards = headerTowardsStr,
headerBack = headerBackStr,
durationTemplate = durationTemplate,
durationTemplate = durationFormatStr,
statusGreen = statusGreen,
statusYellow = statusYellow,
statusOrange = statusOrange,
@ -335,7 +336,7 @@ private fun showTracerouteDetail(
statusYellow = statusYellow,
statusOrange = statusOrange,
)
val durationText = durationTemplate.replace("%SECS%", formatString("%.1f", seconds))
val durationText = formatString(durationTemplate, NumberFormatter.format(seconds, 1))
buildAnnotatedString {
append(annotatedBase)
append("\n\n$durationText")

View file

@ -31,7 +31,7 @@ import org.meshtastic.feature.node.list.NodeListViewModel
fun AdaptiveNodeListScreen(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()

View file

@ -73,7 +73,7 @@ import kotlin.reflect.KClass
fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoute.NodesGraph>(metadata = { ListDetailSceneStrategy.listPane() }) {
AdaptiveNodeListScreen(
@ -99,7 +99,7 @@ fun EntryProviderScope<NavKey>.nodesGraph(
fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoute.NodeDetailGraph>(metadata = { ListDetailSceneStrategy.listPane() }) { args ->
AdaptiveNodeListScreen(

View file

@ -35,7 +35,7 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.Buffer
import okio.BufferedSink
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
@ -210,7 +210,7 @@ class MetricsViewModelTest {
awaitItem() // Empty
awaitItem() // with position
val uri = MeshtasticUri("content://test")
val uri = CommonUri.parse("content://test")
vm.savePositionCSV(uri, listOf(testPosition))
runCurrent()

View file

@ -30,15 +30,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.WifiProvisionRoute
@ -89,14 +90,14 @@ fun SettingsScreen(
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var deviceProfile by remember { mutableStateOf<DeviceProfile?>(null) }
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
var showEditDeviceProfileDialog by rememberSaveable { mutableStateOf(false) }
val importConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
showEditDeviceProfileDialog = true
it.data?.data?.let { uri ->
viewModel.importProfile(uri.toMeshtasticUri()) { profile -> deviceProfile = profile }
viewModel.importProfile(uri.toKmpUri()) { profile -> deviceProfile = profile }
}
}
}
@ -104,7 +105,7 @@ fun SettingsScreen(
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.exportProfile(uri.toMeshtasticUri(), deviceProfile!!) }
it.data?.data?.let { uri -> viewModel.exportProfile(uri.toKmpUri(), deviceProfile!!) }
}
}
@ -143,12 +144,12 @@ fun SettingsScreen(
)
}
var showLanguagePickerDialog by remember { mutableStateOf(false) }
var showLanguagePickerDialog by rememberSaveable { mutableStateOf(false) }
if (showLanguagePickerDialog) {
LanguagePickerDialog { showLanguagePickerDialog = false }
}
var showThemePickerDialog by remember { mutableStateOf(false) }
var showThemePickerDialog by rememberSaveable { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
@ -249,7 +250,7 @@ fun SettingsScreen(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "",
onExportData = { settingsViewModel.saveDataCsv(it.toMeshtasticUri()) },
onExportData = { settingsViewModel.saveDataCsv(it.toKmpUri()) },
)
AppInfoSection(

View file

@ -30,9 +30,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.export_keys
import org.meshtastic.core.resources.export_keys_confirmation
@ -54,7 +54,7 @@ actual fun ExportSecurityConfigButton(
val exportConfigLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toMeshtasticUri(), securityConfig) }
it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) }
}
}

View file

@ -28,7 +28,7 @@ import okio.BufferedSink
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
@ -187,7 +187,7 @@ class SettingsViewModel(
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
fun saveDataCsv(uri: MeshtasticUri, filterPortnum: Int? = null) {
fun saveDataCsv(uri: CommonUri, filterPortnum: Int? = null) {
safeLaunch(tag = "saveDataCsv") {
fileService.write(uri) { writer -> performDataExport(writer, filterPortnum) }
}

View file

@ -35,7 +35,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -158,7 +158,7 @@ fun DebugSearchState(
onExportLogs: (() -> Unit)? = null,
) {
val colorScheme = MaterialTheme.colorScheme
var customFilterText by remember { mutableStateOf("") }
var customFilterText by rememberSaveable { mutableStateOf("") }
Column(modifier = modifier.background(color = colorScheme.background.copy(alpha = 1.0f)).padding(8.dp)) {
Row(

View file

@ -61,15 +61,6 @@ import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String)
data class SearchState(
val searchText: String = "",
val currentMatchIndex: Int = -1,
val allMatches: List<SearchMatch> = emptyList(),
val hasMatches: Boolean = false,
)
enum class FilterMode {
AND,
OR,
@ -387,17 +378,15 @@ class DebugViewModel(
val nodeIdStr = nodeId.toUInt().toString()
// Only match if whitespace before and after
val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""")
regex.find(this)?.let { _ ->
regex.findAll(this).toList().asReversed().forEach {
val idx = it.range.last + 1
insert(idx, " (${nodeId.toHex(8)})")
}
return true
if (!regex.containsMatchIn(this)) return false
regex.findAll(this).toList().asReversed().forEach {
val idx = it.range.last + 1
insert(idx, " (${nodeId.toHex(8)})")
}
return false
return true
}
private fun Int.toHex(length: Int): String = "!" + this.toUInt().toString(16).padStart(length, '0')
private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}"
fun requestDeleteAllLogs() {
alertManager.showAlert(

View file

@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -80,7 +79,7 @@ fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewMod
.lastOrNull { it is SettingsRoute.SettingsGraph }
?.let { (it as SettingsRoute.SettingsGraph).destNum }
}
SideEffect { viewModel.initDestNum(destNum) }
LaunchedEffect(destNum) { viewModel.initDestNum(destNum) }
return viewModel
}

View file

@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.update
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
@ -384,7 +384,7 @@ open class RadioConfigViewModel(
safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) }
}
fun importProfile(uri: MeshtasticUri, onResult: (DeviceProfile) -> Unit) {
fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) {
safeLaunch(tag = "importProfile") {
var profile: DeviceProfile? = null
fileService.read(uri) { source ->
@ -394,7 +394,7 @@ open class RadioConfigViewModel(
}
}
fun exportProfile(uri: MeshtasticUri, profile: DeviceProfile) {
fun exportProfile(uri: CommonUri, profile: DeviceProfile) {
safeLaunch(tag = "exportProfile") {
fileService.write(uri) { sink ->
exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it }
@ -402,7 +402,7 @@ open class RadioConfigViewModel(
}
}
fun exportSecurityConfig(uri: MeshtasticUri, securityConfig: Config.SecurityConfig) {
fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) {
safeLaunch(tag = "exportSecurityConfig") {
fileService.write(uri) { sink ->
exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }

View file

@ -113,9 +113,9 @@ private fun ChannelConfigScreen(
onPositiveClicked: (List<ChannelSettings>) -> Unit,
) {
val primarySettings = settingsList.getOrNull(0) ?: return
val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) }
val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) }
val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) }
val modemPresetName = remember(loraConfig) { Channel(loraConfig = loraConfig).name }
val primaryChannel = remember(loraConfig) { Channel(primarySettings, loraConfig) }
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val focusManager = LocalFocusManager.current
val settingsListInput =
@ -141,7 +141,7 @@ private fun ChannelConfigScreen(
if (showEditChannelDialog != null) {
val index = showEditChannelDialog ?: return
EditChannelDialog(
channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() },
channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(),
modemPresetName = modemPresetName,
onAddClick = {
if (settingsListInput.size > index) {

View file

@ -124,7 +124,7 @@ fun ChannelScreen(
val modemPresetName by
remember(channels) { mutableStateOf(Channel(loraConfig = channels.lora_config ?: Config.LoRaConfig()).name) }
var showResetDialog by remember { mutableStateOf(false) }
var showResetDialog by rememberSaveable { mutableStateOf(false) }
var shouldAddChannelsState by remember { mutableStateOf(true) }
@ -211,7 +211,7 @@ fun ChannelScreen(
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelUrl() }) }
var showShareDialog by remember { mutableStateOf(false) }
var showShareDialog by rememberSaveable { mutableStateOf(false) }
if (showShareDialog) {
ChannelShareDialog(

View file

@ -71,7 +71,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val primarySettings = state.channelList.getOrNull(0) ?: return
val formState = rememberConfigState(initialValue = loraConfig)
val primaryChannel by remember(formState.value) { mutableStateOf(Channel(primarySettings, formState.value)) }
val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(

View file

@ -36,6 +36,7 @@ import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.feature.wifiprovision.NymeaBleConstants
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN
@ -88,7 +89,7 @@ class NymeaWifiService(
* @return The discovered device's advertised name on success.
* @throws IllegalStateException if no device is found within [SCAN_TIMEOUT].
*/
suspend fun connect(address: String? = null): Result<String> = runCatching {
suspend fun connect(address: String? = null): Result<String> = safeCatching {
Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" }
val device =
@ -138,7 +139,7 @@ class NymeaWifiService(
*
* Sends: CMD_SCAN (4), waits for ack, then CMD_GET_NETWORKS (0).
*/
suspend fun scanNetworks(): Result<List<WifiNetwork>> = runCatching {
suspend fun scanNetworks(): Result<List<WifiNetwork>> = safeCatching {
// Trigger scan
sendCommand(NymeaJson.encodeToString(NymeaSimpleCommand(CMD_SCAN)))
val scanAck = NymeaJson.decodeFromString<NymeaResponse>(waitForResponse())
@ -180,7 +181,7 @@ class NymeaWifiService(
NymeaConnectCommand(command = cmd, params = NymeaConnectParams(ssid = ssid, password = password)),
)
return runCatching {
return safeCatching {
sendCommand(json)
val response = NymeaJson.decodeFromString<NymeaResponse>(waitForResponse())
if (response.responseCode == RESPONSE_SUCCESS) {