mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
50ade01e55
commit
72b981f73b
132 changed files with 2186 additions and 916 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue