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

@ -0,0 +1,42 @@
/*
* 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
class CommonUtilsTest {
@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,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)
}
}