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

@ -48,9 +48,7 @@ suspend fun <T> retryBleOperation(
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
Logger.w(e) {
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
}
Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
delay(delayMs)
}
}

View file

@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers:
- **Time**: Utilities for handling timestamps and durations.
- **Exceptions**: Standardized exception types for common error scenarios.
### 2. `ByteUtils.kt`
Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
### 2. `MetricFormatter.kt`
Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces.
### 3. `BuildConfigProvider.kt`
An interface for accessing build-time configuration in a multiplatform-friendly way.

View file

@ -37,6 +37,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
api(libs.uri.kmp)
implementation(libs.kermit)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }

View file

@ -1,45 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.net.Uri
actual class CommonUri(private val uri: Uri) {
actual val host: String?
get() = uri.host
actual val fragment: String?
get() = uri.fragment
actual val pathSegments: List<String>
get() = uri.pathSegments
actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
uri.getBooleanQueryParameter(key, defaultValue)
actual override fun toString(): String = uri.toString()
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
}
fun toUri(): Uri = uri
}
actual fun CommonUri.toPlatformUri(): Any = this.toUri()

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.net.Uri
/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */
fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString)
/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */
fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString())

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common
/** Utility function to make it easy to declare byte arrays */
fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) }
private const val BYTE_MASK = 0xff

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,13 +17,14 @@
package org.meshtastic.core.common.util
/**
* A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain
* modules without coupling them to the android.net.Uri class.
* Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
* blank, or sentinel values (`"N"`, `"NULL"`).
*/
data class MeshtasticUri(val uriString: String) {
override fun toString(): String = uriString
companion object {
fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
fun normalizeAddress(addr: String?): String {
val u = addr?.trim()?.uppercase()
return when {
u.isNullOrBlank() -> "DEFAULT"
u == "N" || u == "NULL" -> "DEFAULT"
else -> u.replace(":", "")
}
}

View file

@ -16,22 +16,14 @@
*/
package org.meshtastic.core.common.util
/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
expect class CommonUri {
val host: String?
val fragment: String?
val pathSegments: List<String>
import com.eygraber.uri.Uri
fun getQueryParameter(key: String): String?
fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
override fun toString(): String
companion object {
fun parse(uriString: String): CommonUri
}
}
/** Extension to convert platform Uri to CommonUri in Android source sets. */
expect fun CommonUri.toPlatformUri(): Any
/**
* Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp).
*
* This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works
* identically on Android, JVM, and iOS without platform stubs.
*
* On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`.
*/
typealias CommonUri = Uri

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
object Exceptions {
/** Set by the application to provide a custom crash reporting implementation. */
@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
/** Suspend-compatible variant of [ignoreException]. */
/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
try {
inner()
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
@ -69,3 +72,26 @@ fun exceptionReporter(inner: () -> Unit) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
/**
* Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead
* of [runCatching] in coroutine contexts.
*/
@Suppress("TooGenericExceptionCaught")
inline fun <T> safeCatching(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */
@Suppress("TooGenericExceptionCaught")
inline fun <T, R> T.safeCatching(block: T.() -> R): Result<R> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}

View file

@ -16,5 +16,114 @@
*/
package org.meshtastic.core.common.util
/** Multiplatform string formatting helper. */
expect fun formatString(pattern: String, vararg args: Any?): String
/**
* Pure-Kotlin multiplatform string formatting.
*
* Implements the subset of Java's `String.format()` patterns used in this codebase:
* - `%s`, `%d` positional or sequential string/integer
* - `%N$s`, `%N$d` explicit positional string/integer
* - `%N$.Nf`, `%.Nf` float with decimal precision
* - `%x`, `%X`, `%08x` hexadecimal (lower/upper, optional zero-padded width)
* - `%%` literal percent
*/
@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements")
fun formatString(pattern: String, vararg args: Any?): String = buildString {
var i = 0
var autoIndex = 0
while (i < pattern.length) {
if (pattern[i] != '%') {
append(pattern[i])
i++
continue
}
i++ // skip '%'
if (i >= pattern.length) break
// Literal %%
if (pattern[i] == '%') {
append('%')
i++
continue
}
// Parse optional positional index (N$)
var explicitIndex: Int? = null
val startPos = i
while (i < pattern.length && pattern[i].isDigit()) i++
if (i < pattern.length && pattern[i] == '$' && i > startPos) {
explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
i++ // skip '$'
} else {
i = startPos // rewind — digits are part of width/precision, not positional index
}
// Parse optional flags (zero-pad)
var zeroPad = false
if (i < pattern.length && pattern[i] == '0') {
zeroPad = true
i++
}
// Parse optional width
var width: Int? = null
val widthStart = i
while (i < pattern.length && pattern[i].isDigit()) i++
if (i > widthStart) {
width = pattern.substring(widthStart, i).toInt()
}
// Parse optional precision (.N)
var precision: Int? = null
if (i < pattern.length && pattern[i] == '.') {
i++ // skip '.'
val precStart = i
while (i < pattern.length && pattern[i].isDigit()) i++
if (i > precStart) {
precision = pattern.substring(precStart, i).toInt()
}
}
// Parse conversion character
if (i >= pattern.length) break
val conversion = pattern[i]
i++
val argIndex = explicitIndex ?: autoIndex++
val arg = args.getOrNull(argIndex)
when (conversion) {
's' -> append(arg?.toString() ?: "null")
'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
'f' -> {
val value = (arg as? Number)?.toDouble() ?: 0.0
val places = precision ?: DEFAULT_FLOAT_PRECISION
append(NumberFormatter.format(value, places))
}
'x',
'X',
-> {
val value = (arg as? Number)?.toLong() ?: 0L
// Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
val masked = if (arg is Int) value and INT_MASK else value
var hex = masked.toString(HEX_RADIX)
if (conversion == 'X') hex = hex.uppercase()
val padChar = if (zeroPad) '0' else ' '
val padWidth = width ?: 0
append(hex.padStart(padWidth, padChar))
}
else -> {
// Unknown conversion — reproduce original token
append('%')
if (explicitIndex != null) append("${explicitIndex + 1}$")
if (zeroPad) append('0')
if (width != null) append(width)
if (precision != null) append(".$precision")
append(conversion)
}
}
}
}
private const val DEFAULT_FLOAT_PRECISION = 6
private const val HEX_RADIX = 16
private const val INT_MASK = 0xFFFFFFFFL

View file

@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer {
* @param value original string value.
* @return optimized string value.
*/
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
val stringBuilder = StringBuilder()
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
return stringBuilder.toString()
fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/**
* Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node,
* NodeItem, and metric screens.
*
* All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional
* for a mesh networking app where consistency matters.
*/
object MetricFormatter {
fun temperature(celsius: Float, isFahrenheit: Boolean): String {
val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius
val unit = if (isFahrenheit) "°F" else "°C"
return "${NumberFormatter.format(value, 1)}$unit"
}
fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V"
fun current(milliAmps: Float, decimalPlaces: Int = 1): String =
"${NumberFormatter.format(milliAmps, decimalPlaces)} mA"
fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%"
fun percent(value: Int): String = "$value%"
fun humidity(value: Float): String = percent(value, 0)
fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
fun rssi(value: Int): String = "$value dBm"
}
private const val FAHRENHEIT_SCALE = 1.8f
private const val FAHRENHEIT_OFFSET = 32

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class AddressUtilsTest {
@Test
fun nullReturnsDefault() {
assertEquals("DEFAULT", normalizeAddress(null))
}
@Test
fun blankReturnsDefault() {
assertEquals("DEFAULT", normalizeAddress(""))
assertEquals("DEFAULT", normalizeAddress(" "))
}
@Test
fun sentinelNReturnsDefault() {
assertEquals("DEFAULT", normalizeAddress("N"))
assertEquals("DEFAULT", normalizeAddress("n"))
}
@Test
fun sentinelNullReturnsDefault() {
assertEquals("DEFAULT", normalizeAddress("NULL"))
assertEquals("DEFAULT", normalizeAddress("null"))
assertEquals("DEFAULT", normalizeAddress("Null"))
}
@Test
fun stripsColons() {
assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD"))
}
@Test
fun uppercases() {
assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd"))
}
@Test
fun trimsWhitespace() {
assertEquals("AABBCC", normalizeAddress(" AA:BB:CC "))
}
@Test
fun alreadyNormalizedPassesThrough() {
assertEquals("AABBCCDD", normalizeAddress("AABBCCDD"))
}
@Test
fun mixedCaseWithColons() {
assertEquals("AABBCC", normalizeAddress("aA:Bb:cC"))
}
}

View file

@ -19,11 +19,25 @@ package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class MeshtasticUriTest {
class CommonUriTest {
@Test
fun testParseAndToString() {
val uriString = "content://com.example.provider/file.txt"
val uri = MeshtasticUri.parse(uriString)
val uri = CommonUri.parse(uriString)
assertEquals(uriString, uri.toString())
}
@Test
fun testQueryParameters() {
val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true")
assertEquals("meshtastic.org", uri.host)
assertEquals("key=value&complete=true", uri.fragment)
}
@Test
fun testFileUri() {
val uri = CommonUri.parse("file:///tmp/export.csv")
assertEquals("file", uri.scheme)
assertEquals("/tmp/export.csv", uri.path)
}
}

View file

@ -93,4 +93,48 @@ class FormatStringTest {
fun sequentialFloatSubstitution() {
assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45))
}
// Hex format tests
@Test
fun lowercaseHex() {
assertEquals("ff", formatString("%x", 255))
}
@Test
fun uppercaseHex() {
assertEquals("FF", formatString("%X", 255))
}
@Test
fun zeroPaddedHex() {
assertEquals("000000ff", formatString("%08x", 255))
}
@Test
fun zeroPaddedHexNodeId() {
assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt()))
}
@Test
fun hexZeroValue() {
assertEquals("00000000", formatString("%08x", 0))
}
@Test
fun positionalHex() {
assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42))
}
// Edge case tests
@Test
fun trailingPercent() {
assertEquals("hello", formatString("hello%"))
}
@Test
fun outOfBoundsArgIndex() {
assertEquals("null", formatString("%3\$s", "only_one"))
}
}

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class MetricFormatterTest {
@Test
fun temperatureCelsius() {
assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false))
}
@Test
fun temperatureFahrenheit() {
assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true))
}
@Test
fun temperatureNegative() {
assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false))
}
@Test
fun voltage() {
assertEquals("3.72 V", MetricFormatter.voltage(3.72f))
}
@Test
fun voltageOneDecimal() {
assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1))
}
@Test
fun current() {
assertEquals("150.3 mA", MetricFormatter.current(150.3f))
}
@Test
fun percentFloat() {
assertEquals("85.5%", MetricFormatter.percent(85.5f))
}
@Test
fun percentInt() {
assertEquals("85%", MetricFormatter.percent(85))
}
@Test
fun humidity() {
assertEquals("65%", MetricFormatter.humidity(65.4f))
}
@Test
fun pressure() {
assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f))
}
@Test
fun snr() {
assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
}
@Test
fun rssi() {
assertEquals("-90 dBm", MetricFormatter.rssi(-90))
}
@Test
fun temperatureFreezingFahrenheit() {
assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true))
}
@Test
fun temperatureBoilingFahrenheit() {
assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true))
}
@Test
fun voltageZero() {
assertEquals("0.00 V", MetricFormatter.voltage(0.0f))
}
@Test
fun currentZero() {
assertEquals("0.0 mA", MetricFormatter.current(0.0f))
}
@Test
fun percentZero() {
assertEquals("0%", MetricFormatter.percent(0))
}
@Test
fun percentHundred() {
assertEquals("100%", MetricFormatter.percent(100))
}
@Test
fun rssiZero() {
assertEquals("0 dBm", MetricFormatter.rssi(0))
}
@Test
fun snrNegative() {
assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))
}
}

View file

@ -1,130 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/**
* Apple (iOS) implementation of string formatting.
*
* Implements a subset of Java's `String.format()` patterns used in this codebase:
* - `%s`, `%d` positional or sequential string/integer
* - `%N$s`, `%N$d` explicit positional string/integer
* - `%N$.Nf`, `%.Nf` float with decimal precision
* - `%x`, `%X`, `%08x` hexadecimal (lower/upper, optional zero-padded width)
* - `%%` literal percent
*
* This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions).
*/
actual fun formatString(pattern: String, vararg args: Any?): String = buildString {
var i = 0
var autoIndex = 0
while (i < pattern.length) {
if (pattern[i] != '%') {
append(pattern[i])
i++
continue
}
i++ // skip '%'
if (i >= pattern.length) break
// Literal %%
if (pattern[i] == '%') {
append('%')
i++
continue
}
// Parse optional positional index (N$)
var explicitIndex: Int? = null
val startPos = i
while (i < pattern.length && pattern[i].isDigit()) i++
if (i < pattern.length && pattern[i] == '$' && i > startPos) {
explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
i++ // skip '$'
} else {
i = startPos // rewind — digits are part of width/precision, not positional index
}
// Parse optional flags (zero-pad)
var zeroPad = false
if (i < pattern.length && pattern[i] == '0') {
zeroPad = true
i++
}
// Parse optional width
var width: Int? = null
val widthStart = i
while (i < pattern.length && pattern[i].isDigit()) i++
if (i > widthStart) {
width = pattern.substring(widthStart, i).toInt()
}
// Parse optional precision (.N)
var precision: Int? = null
if (i < pattern.length && pattern[i] == '.') {
i++ // skip '.'
val precStart = i
while (i < pattern.length && pattern[i].isDigit()) i++
if (i > precStart) {
precision = pattern.substring(precStart, i).toInt()
}
}
// Parse conversion character
if (i >= pattern.length) break
val conversion = pattern[i]
i++
val argIndex = explicitIndex ?: autoIndex++
val arg = args.getOrNull(argIndex)
when (conversion) {
's' -> append(arg?.toString() ?: "null")
'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
'f' -> {
val value = (arg as? Number)?.toDouble() ?: 0.0
val places = precision ?: DEFAULT_FLOAT_PRECISION
append(NumberFormatter.format(value, places))
}
'x',
'X',
-> {
val value = (arg as? Number)?.toLong() ?: 0L
// Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
val masked = if (arg is Int) value and INT_MASK else value
var hex = masked.toString(HEX_RADIX)
if (conversion == 'X') hex = hex.uppercase()
val padChar = if (zeroPad) '0' else ' '
val padWidth = width ?: 0
append(hex.padStart(padWidth, padChar))
}
else -> {
// Unknown conversion — reproduce original token
append('%')
if (explicitIndex != null) append("${explicitIndex + 1}$")
if (zeroPad) append('0')
if (width != null) append(width)
if (precision != null) append(".$precision")
append(conversion)
}
}
}
}
private const val DEFAULT_FLOAT_PRECISION = 6
private const val HEX_RADIX = 16
private const val INT_MASK = 0xFFFFFFFFL

View file

@ -22,20 +22,6 @@ actual object BuildUtils {
actual val sdkInt: Int = 0
}
actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List<String>) {
actual fun getQueryParameter(key: String): String? = null
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
actual override fun toString(): String = ""
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
}
}
actual fun CommonUri.toPlatformUri(): Any = Any()
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = ""

View file

@ -1,20 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** JVM/Android implementation of string formatting. */
actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import java.net.URI
actual class CommonUri(private val uri: URI) {
private val queryParameters: Map<String, List<String>> by lazy { parseQueryParameters(uri.rawQuery) }
actual val host: String?
get() = uri.host
actual val fragment: String?
get() = uri.fragment
actual val pathSegments: List<String>
get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() }
actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
val value = getQueryParameter(key) ?: return defaultValue
return value != "false" && value != "0"
}
actual override fun toString(): String = uri.toString()
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
}
fun toUri(): URI = uri
}
actual fun CommonUri.toPlatformUri(): Any = this.toUri()

View file

@ -17,9 +17,6 @@
package org.meshtastic.core.common.util
import java.net.InetAddress
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.text.DateFormat
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@ -76,7 +73,7 @@ actual object DateFormatter {
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
}
@Suppress("MagicNumber")
@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean {
}
}
internal fun parseQueryParameters(rawQuery: String?): Map<String, List<String>> = rawQuery
?.split('&')
?.filter { it.isNotBlank() }
?.groupBy(
keySelector = { segment ->
val key = segment.substringBefore('=', missingDelimiterValue = segment)
URLDecoder.decode(key, StandardCharsets.UTF_8.name())
},
valueTransform = { segment ->
val value = segment.substringAfter('=', missingDelimiterValue = "")
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
},
)
.orEmpty()
private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}")
private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,63}${'$'}")

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CommonUriTest {
@Test
fun testParse() {
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1&param2=true#fragment")
assertEquals("meshtastic.org", uri.host)
assertEquals("fragment", uri.fragment)
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
assertEquals("value1", uri.getQueryParameter("param1"))
assertTrue(uri.getBooleanQueryParameter("param2", false))
}
@Test
fun testBooleanParameters() {
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
assertTrue(uri.getBooleanQueryParameter("t1", false))
assertTrue(uri.getBooleanQueryParameter("t2", false))
assertTrue(uri.getBooleanQueryParameter("t3", false))
assertTrue(!uri.getBooleanQueryParameter("f1", true))
assertTrue(!uri.getBooleanQueryParameter("f2", true))
}
}

View file

@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
"lastRequest=$lastRequest window=$window max=$max",
)
runCatching {
safeCatching {
packetHandler.sendToRadio(
MeshPacket(
from = myNodeNum,

View file

@ -26,6 +26,7 @@ import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
@ -93,7 +94,7 @@ class MeshActionHandlerImpl(
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
runCatching {
safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)

View file

@ -289,7 +289,7 @@ class MeshConnectionManagerImpl(
override fun onRadioConfigLoaded() {
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
val queuedPackets = packetRepository.getQueuedPackets()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)

View file

@ -96,7 +96,7 @@ class MeshMessageProcessorImpl(
}
.onFailure { _ ->
Logger.e(primaryException) {
"Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
"Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord."
}
}
}

View file

@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl(
}
// 2. Fetch from remote API
runCatching {
safeCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl(
hwModel: Int,
target: String?,
quirks: List<BootloaderOtaQuirk>,
): Result<DeviceHardware?> = runCatching {
): Result<DeviceHardware?> = safeCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl(
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
runCatching {
safeCatching {
Logger.d { "Fetching fresh firmware releases from remote API." }
val networkReleases = remoteDataSource.getFirmwareReleases()
@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl(
// If remote fetch failed, try the JSON fallback as a last resort.
if (!remoteFetchSuccess) {
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
runCatching {
safeCatching {
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)

View file

@ -108,7 +108,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
dao.upsertContactSettings(listOf(updated))
}
override suspend fun getQueuedPackets(): List<DataPacket>? =
override suspend fun getQueuedPackets(): List<DataPacket> =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insertRoomPacket(packet: RoomPacket) =

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.database
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.common.util.normalizeAddress
object DatabaseConstants {
const val DB_PREFIX: String = "meshtastic_database"
@ -40,17 +41,6 @@ object DatabaseConstants {
const val ADDRESS_ANON_EDGE_LEN: Int = 2
}
fun normalizeAddress(addr: String?): String {
val u = addr?.trim()?.uppercase()
val normalized =
when {
u.isNullOrBlank() -> "DEFAULT"
u == "N" || u == "NULL" -> "DEFAULT"
else -> u.replace(":", "")
}
return normalized
}
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {

View file

@ -241,6 +241,7 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
// runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
@ -266,6 +267,7 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
// runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}

View file

@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
],
version = 37,
version = 38,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)

View file

@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog
@Dao
interface MeshLogDao {
@Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
@Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem")
fun getAllLogs(maxItem: Int): Flow<List<MeshLog>>
@Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
@Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem")
fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>>
/**
@ -40,7 +40,7 @@ interface MeshLogDao {
"""
SELECT * FROM log
WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum)
ORDER BY received_date DESC LIMIT 0,:maxItem
ORDER BY received_date DESC LIMIT :maxItem
""",
)
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow<List<MeshLog>>

View file

@ -35,6 +35,9 @@ interface NodeInfoDao {
companion object {
const val KEY_SIZE = 32
/** SQLite has a limit of ~999 bind parameters per query. */
const val MAX_BIND_PARAMS = 999
}
/**
@ -281,9 +284,15 @@ interface NodeInfoDao {
@Transaction
suspend fun getNodeByNum(num: Int): NodeWithRelations?
@Query("SELECT * FROM nodes WHERE num IN (:nodeNums)")
suspend fun getNodeEntitiesByNums(nodeNums: List<Int>): List<NodeEntity>
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
@Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)")
suspend fun findNodesByPublicKeys(publicKeys: List<ByteString>): List<NodeEntity>
@Upsert suspend fun doUpsert(node: NodeEntity)
@Transaction
@ -297,11 +306,77 @@ interface NodeInfoDao {
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)
/**
* Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two
* queries instead of N individual queries, then processes each node in memory.
*/
@Suppress("NestedBlockDepth")
private suspend fun getVerifiedNodesForUpsert(incomingNodes: List<NodeEntity>): List<NodeEntity> {
// Prepare all incoming nodes (populate denormalized fields)
incomingNodes.forEach { node ->
node.publicKey = node.user.public_key
if (node.user.hw_model != HardwareModel.UNSET) {
node.longName = node.user.long_name
node.shortName = node.user.short_name
} else {
node.longName = null
node.shortName = null
}
}
// Batch fetch all existing nodes by num (chunked for SQLite bind-param limit)
val existingNodesMap =
incomingNodes
.map { it.num }
.chunked(MAX_BIND_PARAMS)
.flatMap { getNodeEntitiesByNums(it) }
.associateBy { it.num }
// Partition into updates vs. inserts and resolve existing nodes in-memory
val result = mutableListOf<NodeEntity>()
val newNodes = mutableListOf<NodeEntity>()
for (incoming in incomingNodes) {
val existing = existingNodesMap[incoming.num]
if (existing != null) {
result.add(handleExistingNodeUpsertValidation(existing, incoming))
} else {
newNodes.add(incoming)
}
}
// Batch validate new nodes' public keys (one query instead of N)
val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct()
val pkConflicts =
if (publicKeysToCheck.isNotEmpty()) {
publicKeysToCheck
.chunked(MAX_BIND_PARAMS)
.flatMap { findNodesByPublicKeys(it) }
.associateBy { it.publicKey }
} else {
emptyMap()
}
for (newNode in newNodes) {
if ((newNode.publicKey?.size ?: 0) > 0) {
val conflicting = pkConflicts[newNode.publicKey]
if (conflicting != null && conflicting.num != newNode.num) {
result.add(conflicting)
} else {
result.add(newNode)
}
} else {
result.add(newNode)
}
}
return result
}
@Transaction
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
clearMyNodeInfo()
setMyNodeInfo(mi)
putAll(nodes.map { getVerifiedNodeForUpsert(it) })
putAll(getVerifiedNodesForUpsert(nodes))
}
/**

View file

@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao
import androidx.paging.PagingSource
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.MapColumn
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Update
@ -326,8 +328,15 @@ interface PacketDao {
)
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
@Transaction
suspend fun getQueuedPackets(): List<DataPacket>? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
@Query(
"""
SELECT data FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND json_extract(data, '${"$"}.status') = 'QUEUED'
ORDER BY received_time ASC
""",
)
suspend fun getQueuedPackets(): List<DataPacket>
@Query(
"""
@ -359,23 +368,24 @@ interface PacketDao {
@Upsert suspend fun upsertContactSettings(contacts: List<ContactSettings>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertContactSettingsIgnore(contacts: List<ContactSettings>)
@Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)")
suspend fun updateMuteUntil(contactKeys: List<String>, muteUntil: Long)
@Transaction
suspend fun setMuteUntil(contacts: List<String>, until: Long) {
val contactList = contacts.map { contact ->
// Always mute
val absoluteMuteUntil =
if (until == Long.MAX_VALUE) {
Long.MAX_VALUE
} else if (until == 0L) { // unmute
0L
} else {
nowMillis + until
}
getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil)
?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil)
}
upsertContactSettings(contactList)
val absoluteMuteUntil =
when {
until == Long.MAX_VALUE -> Long.MAX_VALUE
until == 0L -> 0L
else -> nowMillis + until
}
// Ensure rows exist for all contacts (IGNORE avoids overwriting existing data)
insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) })
// Atomic column-level update — no read-then-write race
updateMuteUntil(contacts, absoluteMuteUntil)
}
@Upsert suspend fun insert(reaction: ReactionEntity)
@ -479,9 +489,10 @@ interface PacketDao {
val indexMap =
oldSettings
.mapIndexed { oldIndex, oldChannel ->
val pskMatches = newSettings.mapIndexedNotNull { index, channel ->
if (channel.psk == oldChannel.psk) index to channel else null
}
val pskMatches =
newSettings.mapIndexedNotNull { index, channel ->
if (channel.psk == oldChannel.psk) index to channel else null
}
val newIndex =
when {

View file

@ -118,6 +118,7 @@ data class MetadataEntity(
Index(value = ["hops_away"]),
Index(value = ["is_favorite"]),
Index(value = ["last_heard", "is_favorite"]),
Index(value = ["public_key"]),
],
)
data class NodeEntity(

View file

@ -74,6 +74,9 @@ data class PacketEntity(
Index(value = ["contact_key"]),
Index(value = ["contact_key", "port_num", "received_time"]),
Index(value = ["packet_id"]),
Index(value = ["received_time"]),
Index(value = ["filtered"]),
Index(value = ["read"]),
],
)
data class Packet(
@ -98,9 +101,12 @@ data class Packet(
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
val candidateRelayNodes = nodes.filter {
it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
}
val candidateRelayNodes =
nodes.filter {
it.num != ourNodeNum &&
it.lastHeard != 0 &&
(it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
}
val closestRelayNode =
if (candidateRelayNodes.size == 1) {

View file

@ -1,51 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import java.text.DateFormat
import kotlin.time.Duration.Companion.hours
private val DAY_DURATION = 24.hours
/**
* Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string
* representing the date.
*
* @param time The time in milliseconds
* @return Formatted date or time string, or null if time is 0
*/
fun getShortDate(time: Long): String? {
if (time == 0L) return null
val instant = time.toInstant()
val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
} else {
DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate())
}
}
/**
* Calculates the remaining mute time in days and hours.
*
* @param remainingMillis The remaining time in milliseconds
* @return Pair of (days, hours), where days is Int and hours is Double
*/

View file

@ -17,12 +17,13 @@
package org.meshtastic.core.model.util
import android.net.Uri
import com.eygraber.uri.toKmpUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */
fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
fun Uri.toCommonUri(): CommonUri = this.toKmpUri()
/** Bridge extension for Android clients. */
fun Uri.dispatchMeshtasticUri(

View file

@ -19,10 +19,9 @@ package org.meshtastic.core.model
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
@ -143,34 +142,26 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
val temp =
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
} else {
formatString("%.1f°C", temperature)
}
MetricFormatter.temperature(temperature ?: 0f, isFahrenheit)
} else {
null
}
val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
formatString("%.1f°C", soil_temperature)
}
MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit)
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
formatString("%d%%", soil_moisture)
MetricFormatter.percent(soil_moisture ?: 0)
} else {
null
}
val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null
val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
@ -199,9 +190,12 @@ data class Node(
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
val candidateRelayNodes = nodes.filter {
it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
}
val candidateRelayNodes =
nodes.filter {
it.num != ourNodeNum &&
it.lastHeard != 0 &&
(it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
}
val closestRelayNode =
if (candidateRelayNodes.size == 1) {

View file

@ -32,7 +32,7 @@ val Any?.anonymize: String
get() = this.anonymize()
/** A version of anonymize that allows passing in a custom minimum length */
fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null"
fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null"
// A toString that makes sure all newlines are removed (for nice logging).
fun Any.toOneLineString() = this.toString().replace('\n', ' ')

View file

@ -16,7 +16,27 @@
*/
package org.meshtastic.core.model.util
import okio.ByteString.Companion.toByteString
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
expect object SfppHasher {
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
object SfppHasher {
private const val HASH_SIZE = 16
private const val INT_BYTES = 4
private const val INT_COUNT = 3
private const val SHIFT_8 = 8
private const val SHIFT_16 = 16
private const val SHIFT_24 = 24
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT)
encryptedPayload.copyInto(input)
var offset = encryptedPayload.size
for (value in intArrayOf(to, from, id)) {
input[offset++] = value.toByte()
input[offset++] = (value shr SHIFT_8).toByte()
input[offset++] = (value shr SHIFT_16).toByte()
input[offset++] = (value shr SHIFT_24).toByte()
}
return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE)
}
}

View file

@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
return if (changes.isEmpty()) {
"No changes detected."
} else {
"Changes:\n" + changes.joinToString("\n")
"Changes:\n${changes.joinToString("\n")}"
}
}

View file

@ -14,12 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common
package org.meshtastic.core.model.util
import kotlin.test.Test
import kotlin.test.assertEquals
class ByteUtilsTest {
class CommonUtilsTest {
@Test
fun testByteArrayOfInts() {

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class SfppHasherTest {
@Test
fun outputIsAlways16Bytes() {
val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1)
assertEquals(16, hash.size)
}
@Test
fun emptyPayloadProduces16Bytes() {
val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0)
assertEquals(16, hash.size)
}
@Test
fun deterministicOutput() {
val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
assertEquals(a.toList(), b.toList())
}
@Test
fun differentPayloadsProduceDifferentHashes() {
val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3)
val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3)
assertNotEquals(a.toList(), b.toList())
}
@Test
fun differentIdsProduceDifferentHashes() {
val payload = byteArrayOf(0x10, 0x20)
val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100)
val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101)
assertNotEquals(a.toList(), b.toList())
}
@Test
fun differentFromProduceDifferentHashes() {
val payload = byteArrayOf(0x10, 0x20)
val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3)
val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3)
assertNotEquals(a.toList(), b.toList())
}
@Test
fun maxIntValues() {
val hash =
SfppHasher.computeMessageHash(
byteArrayOf(0xFF.toByte()),
to = Int.MAX_VALUE,
from = Int.MAX_VALUE,
id = Int.MAX_VALUE,
)
assertEquals(16, hash.size)
}
@Test
fun littleEndianByteOrder() {
// Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian)
val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0)
val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0)
// Different byte orderings must produce different hashes
assertNotEquals(hashA.toList(), hashB.toList())
}
}

View file

@ -20,7 +20,3 @@ package org.meshtastic.core.model.util
actual fun getShortDateTime(time: Long): String = ""
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
actual object SfppHasher {
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
}

View file

@ -1,35 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
actual object SfppHasher {
private const val HASH_SIZE = 16
private const val INT_BYTES = 4
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(encryptedPayload)
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array())
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array())
digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array())
return digest.digest().copyOf(HASH_SIZE)
}
}

View file

@ -28,4 +28,7 @@ object HttpClientDefaults {
/** Maximum number of automatic retries on server errors (5xx). */
const val MAX_RETRIES = 3
/** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */
const val API_BASE_URL = "https://api.meshtastic.org/"
}

View file

@ -326,8 +326,8 @@ class MockRadioTransport(
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
long_name = "Sim " + numIn.toString(16),
short_name = getInitials("Sim " + numIn.toString(16)),
long_name = "Sim ${numIn.toString(16)}",
short_name = getInitials("Sim ${numIn.toString(16)}"),
hw_model = HardwareModel.ANDROID_SIM,
),
position =

View file

@ -35,14 +35,14 @@ interface ApiService {
/**
* Ktor-based [ApiService] implementation.
*
* Uses relative paths the base URL is set via the `DefaultRequest` plugin in the platform Koin modules.
*
* Registered with `binds = []` to prevent Koin from auto-binding to [ApiService]; host modules (`app`, `desktop`)
* provide their own explicit `ApiService` binding to allow platform-specific `HttpClient` engines.
*/
@Single(binds = [])
class ApiServiceImpl(private val client: HttpClient) : ApiService {
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> =
client.get("https://api.meshtastic.org/resource/deviceHardware").body()
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = client.get("resource/deviceHardware").body()
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
client.get("https://api.meshtastic.org/github/firmware/list").body()
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
}

View file

@ -17,12 +17,12 @@
package org.meshtastic.core.network.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface
@ -31,7 +31,7 @@ import javax.jmdns.ServiceEvent
import javax.jmdns.ServiceListener
@Single
class JvmServiceDiscovery : ServiceDiscovery {
class JvmServiceDiscovery(private val dispatchers: CoroutineDispatchers) : ServiceDiscovery {
@Suppress("TooGenericExceptionCaught")
override val resolvedServices: Flow<List<DiscoveredService>> =
callbackFlow {
@ -98,7 +98,7 @@ class JvmServiceDiscovery : ServiceDiscovery {
}
}
}
.flowOn(Dispatchers.IO)
.flowOn(dispatchers.io)
companion object {
/**

View file

@ -17,16 +17,23 @@
package org.meshtastic.core.network.repository
import app.cash.turbine.test
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.di.CoroutineDispatchers
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class JvmServiceDiscoveryTest {
private val testDispatchers =
UnconfinedTestDispatcher().let { dispatcher ->
CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
}
@Test
fun `resolvedServices emits initial empty list immediately`() = runTest {
val discovery = JvmServiceDiscovery()
val discovery = JvmServiceDiscovery(testDispatchers)
discovery.resolvedServices.test {
val first = awaitItem()
assertNotNull(first, "First emission should not be null")

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.normalizeAddress
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.cachedFlow
import org.meshtastic.core.repository.MeshPrefs
@ -95,15 +96,6 @@ class MeshPrefsImpl(
private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
private fun normalizeAddress(address: String?): String {
val raw = address?.trim()?.takeIf { it.isNotEmpty() }
return when {
raw == null -> "DEFAULT"
raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
else -> raw.uppercase().replace(":", "")
}
}
companion object {
val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address")
}

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.repository
import okio.BufferedSink
import okio.BufferedSource
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.CommonUri
/**
* Abstracts file system operations (like reading from or writing to URIs) so that ViewModels can remain
@ -29,11 +29,11 @@ interface FileService {
* Opens a file or URI for writing and provides a [BufferedSink]. The sink is automatically closed after [block]
* execution. Returns true if successful, false otherwise.
*/
suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean
suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean
/**
* Opens a file or URI for reading and provides a [BufferedSource]. The source is automatically closed after [block]
* execution. Returns true if successful, false otherwise.
*/
suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean
suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean
}

View file

@ -71,7 +71,7 @@ interface PacketRepository {
suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long)
/** Returns all packets currently queued for transmission. */
suspend fun getQueuedPackets(): List<DataPacket>?
suspend fun getQueuedPackets(): List<DataPacket>
/**
* Persists a packet in the database.

View file

@ -384,9 +384,9 @@
<string name="battery">Battery</string>
<string name="channel_utilization">ChUtil</string>
<string name="air_utilization">AirUtil</string>
<string name="device_metrics_percent_value">%1$s: %2$.1f%%</string>
<string name="device_metrics_voltage_value">%1$s: %2$.1f V</string>
<string name="device_metrics_numeric_value">%1$.1f</string>
<string name="device_metrics_percent_value">%1$s: %2$s%%</string>
<string name="device_metrics_voltage_value">%1$s: %2$s V</string>
<string name="device_metrics_numeric_value">%1$s</string>
<string name="device_metrics_label_value">%1$s: %2$s</string>
<string name="temperature">Temp</string>
<string name="humidity">Hum</string>

View file

@ -16,9 +16,11 @@
*/
package org.meshtastic.core.service
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.di.CoroutineDispatchers
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@ -27,10 +29,15 @@ import kotlin.test.assertNotNull
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class AndroidFileServiceTest {
private val testDispatchers =
UnconfinedTestDispatcher().let { dispatcher ->
CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
}
@Test
fun testInitialization() = runTest {
val context = RuntimeEnvironment.getApplication()
val service = AndroidFileService(context)
val service = AndroidFileService(context, testDispatchers)
assertNotNull(service)
}
}

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.service
import android.app.Application
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import com.eygraber.uri.toAndroidUri
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.BufferedSource
@ -26,15 +26,16 @@ import okio.buffer
import okio.sink
import okio.source
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.toAndroidUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FileService
import java.io.FileOutputStream
@Single
class AndroidFileService(private val context: Application) : FileService {
override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
withContext(Dispatchers.IO) {
class AndroidFileService(private val context: Application, private val dispatchers: CoroutineDispatchers) :
FileService {
override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
withContext(dispatchers.io) {
try {
val pfd = context.contentResolver.openFileDescriptor(uri.toAndroidUri(), "wt")
if (pfd == null) {
@ -51,8 +52,8 @@ class AndroidFileService(private val context: Application) : FileService {
}
}
override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
withContext(Dispatchers.IO) {
override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
withContext(dispatchers.io) {
try {
val success =
context.contentResolver.openInputStream(uri.toAndroidUri())?.use { inputStream ->

View file

@ -17,7 +17,6 @@
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.BufferedSource
@ -25,17 +24,18 @@ import okio.buffer
import okio.sink
import okio.source
import org.koin.core.annotation.Single
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 java.io.File
@Single
class JvmFileService : FileService {
override suspend fun write(uri: MeshtasticUri, block: suspend (BufferedSink) -> Unit): Boolean =
withContext(Dispatchers.IO) {
class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService {
override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean =
withContext(dispatchers.io) {
try {
// Treat uriString as a local file path
val file = File(uri.uriString)
// Treat URI string as a local file path
val file = File(uri.toString())
file.parentFile?.mkdirs()
file.sink().buffer().use { sink -> block(sink) }
true
@ -45,10 +45,10 @@ class JvmFileService : FileService {
}
}
override suspend fun read(uri: MeshtasticUri, block: suspend (BufferedSource) -> Unit): Boolean =
withContext(Dispatchers.IO) {
override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean =
withContext(dispatchers.io) {
try {
val file = File(uri.uriString)
val file = File(uri.toString())
file.source().buffer().use { source -> block(source) }
true
} catch (e: Exception) {

View file

@ -20,47 +20,41 @@ package org.meshtastic.core.takserver
import kotlin.time.Instant
fun CoTMessage.toXml(): String {
val sb = StringBuilder()
sb.append(
fun CoTMessage.toXml(): String = buildString {
append(
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?><event version='2.0' uid='${uid.xmlEscaped()}' type='$type' time='${time.toXmlString()}' start='${start.toXmlString()}' stale='${stale.toXmlString()}' how='$how'><point lat='$latitude' lon='$longitude' hae='$hae' ce='$ce' le='$le'/><detail>",
)
contact?.let {
sb.append(
append(
"<contact endpoint='${it.endpoint ?: DEFAULT_TAK_ENDPOINT}' callsign='${it.callsign.xmlEscaped()}'/><uid Droid='${it.callsign.xmlEscaped()}'/>",
)
}
group?.let { sb.append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
group?.let { append("<__group role='${it.role.xmlEscaped()}' name='${it.name.xmlEscaped()}'/>") }
status?.let { sb.append("<status battery='${it.battery}'/>") }
status?.let { append("<status battery='${it.battery}'/>") }
track?.let { sb.append("<track course='${it.course}' speed='${it.speed}'/>") }
track?.let { append("<track course='${it.course}' speed='${it.speed}'/>") }
if (chat != null) {
val senderUid = uid.geoChatSenderUid()
val messageId = uid.geoChatMessageId()
sb.append(
append(
"<__chat parent='RootContactGroup' groupOwner='false' messageId='$messageId' chatroom='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}' senderCallsign='${chat.senderCallsign?.xmlEscaped() ?: ""}'><chatgrp uid0='${senderUid.xmlEscaped()}' uid1='${chat.chatroom.xmlEscaped()}' id='${chat.chatroom.xmlEscaped()}'/></__chat>",
)
sb.append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
sb.append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
sb.append(
append("<link uid='${senderUid.xmlEscaped()}' type='a-f-G-U-C' relation='p-p'/>")
append("<__serverdestination destinations='0.0.0.0:4242:tcp:${senderUid.xmlEscaped()}'/>")
append(
"<remarks source='BAO.F.ATAK.${senderUid.xmlEscaped()}' to='${chat.chatroom.xmlEscaped()}' time='${time.toXmlString()}'>${chat.message.xmlEscaped()}</remarks>",
)
} else if (!remarks.isNullOrEmpty()) {
sb.append("<remarks>${remarks.xmlEscaped()}</remarks>")
append("<remarks>${remarks.xmlEscaped()}</remarks>")
}
rawDetailXml?.let {
if (it.isNotEmpty()) {
sb.append(it)
}
}
rawDetailXml?.takeIf { it.isNotEmpty() }?.let { append(it) }
sb.append("</detail></event>")
return sb.toString()
append("</detail></event>")
}
private fun Instant.toXmlString(): String = this.toString()

View file

@ -16,12 +16,16 @@
*/
package org.meshtastic.core.takserver.fountain
import okio.ByteString.Companion.toByteString
internal expect object ZlibCodec {
fun compress(data: ByteArray): ByteArray?
fun decompress(data: ByteArray): ByteArray?
}
internal expect object CryptoCodec {
fun sha256Prefix8(data: ByteArray): ByteArray
internal object CryptoCodec {
private const val PREFIX_SIZE = 8
fun sha256Prefix8(data: ByteArray): ByteArray = data.toByteString().sha256().toByteArray().copyOf(PREFIX_SIZE)
}

View file

@ -24,8 +24,6 @@ import kotlinx.cinterop.ptr
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.value
import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
import platform.zlib.Z_BUF_ERROR
import platform.zlib.Z_OK
import platform.zlib.compress
@ -105,20 +103,3 @@ internal actual object ZlibCodec {
return null
}
}
internal actual object CryptoCodec {
@OptIn(ExperimentalForeignApi::class)
actual fun sha256Prefix8(data: ByteArray): ByteArray {
val digest = ByteArray(CC_SHA256_DIGEST_LENGTH)
if (data.isNotEmpty()) {
data.usePinned { dataPin ->
digest.usePinned { digestPin ->
CC_SHA256(dataPin.addressOf(0), data.size.toUInt(), digestPin.addressOf(0).reinterpret())
}
}
} else {
digest.usePinned { digestPin -> CC_SHA256(null, 0u, digestPin.addressOf(0).reinterpret()) }
}
return digest.copyOf(8)
}
}

View file

@ -17,7 +17,6 @@
package org.meshtastic.core.takserver.fountain
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.zip.Deflater
import java.util.zip.Inflater
@ -66,10 +65,3 @@ internal actual object ZlibCodec {
}
}
}
internal actual object CryptoCodec {
actual fun sha256Prefix8(data: ByteArray): ByteArray {
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(data).copyOf(8)
}
}

View file

@ -20,7 +20,6 @@ package org.meshtastic.core.ui.util
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@ -36,13 +35,14 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
import java.net.URLEncoder
@Composable
@ -107,16 +107,14 @@ actual fun rememberOpenUrl(): (url: String) -> Unit {
@Composable
@Suppress("Wrapping")
actual fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit {
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.data?.let { uri ->
onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) })
}
result.data?.data?.let { uri -> onUriReceived(uri.toKmpUri()) }
}
}
@ -137,7 +135,7 @@ actual fun rememberSaveFileLauncher(
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
onUriReceived(uri?.let { CommonUri(it) })
onUriReceived(uri?.let { it.toKmpUri() })
}
return remember(launcher) { { mimeType -> launcher.launch(mimeType) } }
}
@ -151,7 +149,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
withContext(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
try {
val androidUri = Uri.parse(uri.toString())
val androidUri = uri.toAndroidUri()
context.contentResolver.openInputStream(androidUri)?.use { stream ->
stream.bufferedReader().use { reader ->
val buffer = CharArray(maxChars)

View file

@ -62,12 +62,13 @@ fun <T : Enum<T>> DropDownPreference(
enumEntriesOf(selectedItem).filter { it.name != "UNRECOGNIZED" && !it.isDeprecatedEnumEntry() }
}
val items = enumConstants.map {
val label = itemLabel?.invoke(it) ?: it.name
val icon = itemIcon?.invoke(it)
val color = itemColor?.invoke(it)
DropDownItem(it, label, icon, color)
}
val items =
enumConstants.map {
val label = itemLabel?.invoke(it) ?: it.name
val icon = itemIcon?.invoke(it)
val color = itemColor?.invoke(it)
DropDownItem(it, label, icon, color)
}
DropDownPreference(
title = title,

View file

@ -23,7 +23,7 @@ import androidx.compose.material3.IconToggleButton
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.text.input.ImeAction
@ -49,7 +49,7 @@ fun EditPasswordPreference(
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var isPasswordVisible by remember { mutableStateOf(false) }
var isPasswordVisible by rememberSaveable { mutableStateOf(false) }
EditTextPreference(
title = title,

View file

@ -26,6 +26,7 @@ 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
@ -90,10 +91,10 @@ fun MeshtasticImportFAB(
) {
sharedContact?.let { importDialog(it, onDismissSharedContact) }
var expanded by remember { mutableStateOf(false) }
var showUrlDialog by remember { mutableStateOf(false) }
var isNfcScanning by remember { mutableStateOf(false) }
var showNfcDisabledDialog by remember { mutableStateOf(false) }
var expanded by rememberSaveable { mutableStateOf(false) }
var showUrlDialog by rememberSaveable { mutableStateOf(false) }
var isNfcScanning by rememberSaveable { mutableStateOf(false) }
var showNfcDisabledDialog by rememberSaveable { mutableStateOf(false) }
val openNfcSettings = rememberOpenNfcSettings()
val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } }

View file

@ -41,7 +41,7 @@ import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
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.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
@ -154,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr),
text = "${stringResource(Res.string.snr)} ${MetricFormatter.snr(snr, decimalPlaces = 2)}",
color = color,
style = MaterialTheme.typography.labelSmall,
)
@ -172,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
}
Text(
modifier = modifier,
text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi),
text = "${stringResource(Res.string.rssi)} ${MetricFormatter.rssi(rssi)}",
color = color,
style = MaterialTheme.typography.labelSmall,
)

View file

@ -37,7 +37,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.icon.BatteryEmpty
@ -49,7 +49,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
private const val FORMAT = "%d%%"
private const val SIZE_ICON = 16
@Suppress("MagicNumber", "LongMethod")
@ -60,7 +59,7 @@ fun MaterialBatteryInfo(
voltage: Float? = null,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val levelString = formatString(FORMAT, level)
val levelString = level?.let { MetricFormatter.percent(it) } ?: stringResource(Res.string.unknown)
Row(
modifier = modifier,
@ -130,7 +129,7 @@ fun MaterialBatteryInfo(
?.takeIf { it > 0 }
?.let {
Text(
text = formatString("%.2fV", it),
text = MetricFormatter.voltage(it),
color = contentColor.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp),
)

View file

@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.signal_quality
@ -65,7 +65,10 @@ fun SignalInfo(
tint = signalColor,
)
Text(
text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)),
text =
"${MetricFormatter.snr(
node.snr,
)} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,

View file

@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue
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.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@ -117,8 +118,8 @@ fun EmojiPickerDialog(
onConfirm: (String) -> Unit,
) {
val viewModel: EmojiPickerViewModel = koinViewModel()
var searchQuery by remember { mutableStateOf("") }
var selectedCategoryIndex by remember { mutableStateOf(0) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var selectedCategoryIndex by rememberSaveable { mutableStateOf(0) }
val recentEmojis by
remember(viewModel.customEmojiFrequency) { derivedStateOf { parseRecents(viewModel.customEmojiFrequency) } }
@ -427,7 +428,7 @@ private fun SectionHeader(title: String) {
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EmojiCellWithSkinTone(emoji: Emoji, isSelected: Boolean, onSelect: (String) -> Unit) {
var showSkinTonePopup by remember { mutableStateOf(false) }
var showSkinTonePopup by rememberSaveable { mutableStateOf(false) }
Box {
Box(

View file

@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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
@ -89,7 +90,7 @@ fun ScannedQrCodeDialog(
onDismiss: () -> Unit,
onConfirm: (ChannelSet) -> Unit,
) {
var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) }
var shouldReplace by rememberSaveable { mutableStateOf(incoming.lora_config != null) }
val channelSet =
remember(shouldReplace, channels, incoming) {

View file

@ -21,7 +21,6 @@ package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
/** Returns a function to open the platform's NFC settings. */
@Composable expect fun rememberOpenNfcSettings(): () -> Unit
@ -41,7 +40,7 @@ import org.meshtastic.core.common.util.MeshtasticUri
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
@Composable
expect fun rememberSaveFileLauncher(
onUriReceived: (MeshtasticUri) -> Unit,
onUriReceived: (CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit
/** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */

View file

@ -36,7 +36,6 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
@ -99,18 +98,16 @@ class UIViewModel(
* 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via
* [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
*/
fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) {
val commonUri = CommonUri.parse(uri.uriString)
fun handleDeepLink(uri: CommonUri, onInvalid: () -> Unit = {}) {
// Try navigation routing first
val navKeys = DeepLinkRouter.route(commonUri)
val navKeys = DeepLinkRouter.route(uri)
if (navKeys != null) {
_navigationDeepLink.tryEmit(navKeys)
return
}
// Fallback to channel/contact importing
commonUri.dispatchMeshtasticUri(
uri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,

View file

@ -21,6 +21,7 @@ package org.meshtastic.core.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
@ -37,7 +38,6 @@ import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.unknown_error
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

View file

@ -22,7 +22,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
actual fun createClipEntry(text: String, label: String): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
@ -41,7 +40,7 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
@Composable
actual fun rememberSaveFileLauncher(
onUriReceived: (MeshtasticUri) -> Unit,
onUriReceived: (CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
@Composable

View file

@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
import java.awt.Desktop
import java.awt.FileDialog
import java.awt.Frame
@ -61,7 +60,7 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
/** JVM — Opens a native file dialog to save a file. */
@Composable
actual fun rememberSaveFileLauncher(
onUriReceived: (MeshtasticUri) -> Unit,
onUriReceived: (CommonUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ ->
val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE)
dialog.file = defaultFilename
@ -70,7 +69,7 @@ actual fun rememberSaveFileLauncher(
val dir = dialog.directory
if (file != null && dir != null) {
val path = File(dir, file)
onUriReceived(MeshtasticUri(path.toURI().toString()))
onUriReceived(CommonUri.parse(path.toURI().toString()))
}
}
@ -83,7 +82,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
val dir = dialog.directory
if (file != null && dir != null) {
val path = File(dir, file)
onUriReceived(CommonUri(path.toURI()))
onUriReceived(CommonUri.parse(path.toURI().toString()))
}
}