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

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

@ -1,42 +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
import kotlin.test.Test
import kotlin.test.assertEquals
class ByteUtilsTest {
@Test
fun testByteArrayOfInts() {
val bytes = byteArrayOfInts(0x01, 0xFF, 0x80)
assertEquals(3, bytes.size)
assertEquals(1, bytes[0])
assertEquals(-1, bytes[1]) // 0xFF as signed byte
assertEquals(-128, bytes[2].toInt()) // 0x80 as signed byte
}
@Test
fun testXorHash() {
val data = byteArrayOfInts(0x01, 0x02, 0x03)
assertEquals(0 xor 1 xor 2 xor 3, xorHash(data))
val data2 = byteArrayOfInts(0xFF, 0xFF)
assertEquals(0xFF xor 0xFF, xorHash(data2))
assertEquals(0, xorHash(data2))
}
}

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