mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
chore: KMP audit — commonize code, centralize utilities, eliminate dead abstractions (#5133)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
50ade01e55
commit
72b981f73b
132 changed files with 2186 additions and 916 deletions
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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(":", "")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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}${'$'}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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¶m2=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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue