refactor: KMP Migration, Messaging Modularization, and Handshake Robustness (#4631)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-24 06:37:33 -06:00 committed by GitHub
parent b3f88bd94f
commit d408964f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
144 changed files with 1460 additions and 664 deletions

View file

@ -27,7 +27,6 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
private val ONLINE_WINDOW_HOURS = 2.hours
private val DAY_DURATION = 24.hours
/**
@ -94,13 +93,6 @@ private fun formatUptime(seconds: Long): String {
}
}
/**
* Calculates the threshold in seconds for considering a node "online".
*
* @return The epoch seconds threshold.
*/
fun onlineTimeThreshold(): Int = (nowInstant - ONLINE_WINDOW_HOURS).epochSeconds.toInt()
/**
* Calculates the remaining mute time in days and hours.
*

View file

@ -0,0 +1,19 @@
/*
* 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
actual val isDebug: Boolean = false

View file

@ -0,0 +1,52 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber", "TooGenericExceptionCaught")
package org.meshtastic.core.model.util
import android.graphics.Bitmap
import co.touchlab.kermit.Logger
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import org.meshtastic.proto.ChannelSet
fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try {
val multiFormatWriter = MultiFormatWriter()
val url = getChannelUrl(false, shouldAdd)
val bitMatrix = multiFormatWriter.encode(url.toString(), BarcodeFormat.QR_CODE, 960, 960)
bitMatrix.toBitmap()
} catch (ex: Throwable) {
Logger.e(ex) { "URL was too complex to render as barcode" }
null
}
private fun BitMatrix.toBitmap(): Bitmap {
val width = width
val height = height
val pixels = IntArray(width * height)
for (y in 0 until height) {
val offset = y * width
for (x in 0 until width) {
// Black: 0xFF000000, White: 0xFFFFFFFF
pixels[offset + x] = if (get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
}
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
}

View file

@ -0,0 +1,25 @@
/*
* 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.security.SecureRandom
actual fun platformRandomBytes(size: Int): ByteArray {
val bytes = ByteArray(size)
SecureRandom().nextBytes(bytes)
return bytes
}

View file

@ -0,0 +1,38 @@
/*
* 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 android.net.Uri
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())
/** Bridge extension for Android clients. */
fun Uri.dispatchMeshtasticUri(
onChannel: (ChannelSet) -> Unit,
onContact: (SharedContact) -> Unit,
onInvalid: () -> Unit,
) = this.toCommonUri().dispatchMeshtasticUri(onChannel, onContact, onInvalid)
/** Bridge extension for Android clients. */
fun Uri.toChannelSet(): ChannelSet = this.toCommonUri().toChannelSet()
/** Bridge extension for Android clients. */
fun Uri.toSharedContact(): SharedContact = this.toCommonUri().toSharedContact()

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@ -14,7 +14,6 @@
* 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
import org.junit.Assert.assertEquals

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@ -14,7 +14,6 @@
* 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
import org.junit.Assert

View file

@ -16,13 +16,15 @@
*/
package org.meshtastic.core.model
import org.meshtastic.core.model.util.isDebug
/**
* Defines the capabilities and feature support based on the device firmware version.
*
* This class provides a centralized way to check if specific features are supported by the connected node's firmware.
* Add new features here to ensure consistency across the app.
*/
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = BuildConfig.DEBUG) {
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
private val version = firmwareVersion?.let { DeviceVersion(it) }
private fun isSupported(minVersion: String): Boolean =

View file

@ -19,11 +19,11 @@ package org.meshtastic.core.model
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.byteArrayOfInts
import org.meshtastic.core.model.util.platformRandomBytes
import org.meshtastic.core.model.util.xorHash
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import java.security.SecureRandom
data class Channel(val settings: ChannelSettings = default.settings, val loraConfig: LoRaConfig = default.loraConfig) {
companion object {
@ -59,12 +59,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, hop_limit = 3, tx_enabled = true),
)
fun getRandomKey(size: Int = 32): ByteString {
val bytes = ByteArray(size)
val random = SecureRandom()
random.nextBytes(bytes)
return bytes.toByteString()
}
fun getRandomKey(size: Int = 32): ByteString = platformRandomBytes(size).toByteString()
}
// Return the name of our channel as a human readable string. If empty string, assume "Default" per mesh.proto spec
@ -112,7 +107,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
/** Given a channel name and psk, return the (0 to 255) hash for that channel */
val hash: Int
get() = xorHash(name.toByteArray()) xor xorHash(psk.toByteArray())
get() = xorHash(name.encodeToByteArray()) xor xorHash(psk.toByteArray())
val channelNum: Int
get() = loraConfig.channelNum(name)

View file

@ -0,0 +1,34 @@
/*
* 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
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
@CommonParcelize
data class Contact(
val contactKey: String,
val shortName: String,
val longName: String,
val lastMessageTime: Long?,
val lastMessageText: String?,
val unreadCount: Int,
val messageCount: Int,
val isMuted: Boolean,
val isUnmessageable: Boolean,
val nodeColors: Pair<Int, Int>? = null,
) : CommonParcelable

View file

@ -16,15 +16,15 @@
*/
package org.meshtastic.core.model
import android.os.Parcel
import android.os.Parcelable
import co.touchlab.kermit.Logger
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.Serializable
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonIgnoredOnParcel
import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.CommonTypeParceler
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
@ -32,8 +32,8 @@ import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
@Parcelize
enum class MessageStatus : Parcelable {
@CommonParcelize
enum class MessageStatus : CommonParcelable {
UNKNOWN, // Not set for this message
RECEIVED, // Came in from the mesh
QUEUED, // Waiting to send to the mesh as soon as we connect to the device
@ -46,11 +46,11 @@ enum class MessageStatus : Parcelable {
/** A parcelable version of the protobuf MeshPacket + Data subpacket. */
@Serializable
@Parcelize
@CommonParcelize
data class DataPacket(
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
@CommonTypeParceler<ByteString?, ByteStringParceler>
var bytes: ByteString?,
// A port number for this packet
var dataType: Int,
@ -70,13 +70,13 @@ data class DataPacket(
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
var emoji: Int = 0,
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
@CommonTypeParceler<ByteString?, ByteStringParceler>
var sfppHash: ByteString? = null,
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
var transportMechanism: Int = 0,
) : Parcelable {
) : CommonParcelable {
fun readFromParcel(parcel: Parcel) {
fun readFromParcel(parcel: CommonParcel) {
to = parcel.readString()
bytes = ByteStringParceler.create(parcel)
dataType = parcel.readInt()
@ -102,21 +102,21 @@ data class DataPacket(
hopLimit = parcel.readInt()
channel = parcel.readInt()
wantAck = parcel.readInt() != 0
wantAck = (parcel.readInt() != 0)
hopStart = parcel.readInt()
snr = parcel.readFloat()
rssi = parcel.readInt()
replyId = if (parcel.readInt() == 0) null else parcel.readInt()
relayNode = if (parcel.readInt() == 0) null else parcel.readInt()
relays = parcel.readInt()
viaMqtt = parcel.readInt() != 0
viaMqtt = (parcel.readInt() != 0)
emoji = parcel.readInt()
sfppHash = ByteStringParceler.create(parcel)
transportMechanism = parcel.readInt()
}
/** If there was an error with this message, this string describes what was wrong. */
@IgnoredOnParcel var errorMessage: String? = null
@CommonIgnoredOnParcel var errorMessage: String? = null
/** Syntactic sugar to make it easy to create text messages */
constructor(
@ -173,7 +173,7 @@ data class DataPacket(
}
val hopsAway: Int
get() = if (hopStart == 0 || hopLimit > hopStart) -1 else hopStart - hopLimit
get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit
companion object {
// Special node IDs that can be used for sending messages

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@ -14,7 +14,6 @@
* 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
import co.touchlab.kermit.Logger

View file

@ -16,11 +16,11 @@
*/
package org.meshtastic.core.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
// MyNodeInfo sent via special protobuf from radio
@Parcelize
@CommonParcelize
data class MyNodeInfo(
val myNodeNum: Int,
val hasGPS: Boolean,
@ -37,7 +37,7 @@ data class MyNodeInfo(
val airUtilTx: Float,
val deviceId: String?,
val pioEnv: String? = null,
) : Parcelable {
) : CommonParcelable {
/** A human readable description of the software/hardware version */
val firmwareString: String
get() = "$model $firmwareVersion"

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@ -14,7 +14,6 @@
* 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
import kotlinx.serialization.ExperimentalSerializationApi

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 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
@ -14,7 +14,6 @@
* 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
import kotlinx.serialization.SerialName

View file

@ -16,13 +16,12 @@
*/
package org.meshtastic.core.model
import android.graphics.Color
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
@ -31,7 +30,7 @@ import org.meshtastic.proto.HardwareModel
// model objects that directly map to the corresponding protobufs
//
@Parcelize
@CommonParcelize
data class MeshUser(
val id: String,
val longName: String,
@ -39,7 +38,7 @@ data class MeshUser(
val hwModel: HardwareModel,
val isLicensed: Boolean = false,
val role: Int = 0,
) : Parcelable {
) : CommonParcelable {
override fun toString(): String = "MeshUser(id=${id.anonymize}, " +
"longName=${longName.anonymize}, " +
@ -66,7 +65,7 @@ data class MeshUser(
}
}
@Parcelize
@CommonParcelize
data class Position(
val latitude: Double,
val longitude: Double,
@ -76,7 +75,7 @@ data class Position(
val groundSpeed: Int = 0,
val groundTrack: Int = 0, // "heading"
val precisionBits: Int = 0,
) : Parcelable {
) : CommonParcelable {
@Suppress("MagicNumber")
companion object {
@ -124,7 +123,7 @@ data class Position(
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
}
@Parcelize
@CommonParcelize
data class DeviceMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val batteryLevel: Int = 0,
@ -132,7 +131,7 @@ data class DeviceMetrics(
val channelUtilization: Float,
val airUtilTx: Float,
val uptimeSeconds: Int,
) : Parcelable {
) : CommonParcelable {
companion object {
@Suppress("MagicNumber")
fun currentTime() = nowSeconds.toInt()
@ -152,7 +151,7 @@ data class DeviceMetrics(
)
}
@Parcelize
@CommonParcelize
data class EnvironmentMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val temperature: Float?,
@ -166,7 +165,7 @@ data class EnvironmentMetrics(
val iaq: Int?,
val lux: Float? = null,
val uvLux: Float? = null,
) : Parcelable {
) : CommonParcelable {
@Suppress("MagicNumber")
companion object {
fun currentTime() = nowSeconds.toInt()
@ -189,7 +188,7 @@ data class EnvironmentMetrics(
}
}
@Parcelize
@CommonParcelize
data class NodeInfo(
val num: Int, // This is immutable, and used as a key
var user: MeshUser? = null,
@ -202,7 +201,7 @@ data class NodeInfo(
var environmentMetrics: EnvironmentMetrics? = null,
var hopsAway: Int = 0,
var nodeStatus: String? = null,
) : Parcelable {
) : CommonParcelable {
@Suppress("MagicNumber")
val colors: Pair<Int, Int>
@ -211,7 +210,9 @@ data class NodeInfo(
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
return foreground to background
}
val batteryLevel
@ -222,7 +223,7 @@ data class NodeInfo(
@Suppress("ImplicitDefaultLocale")
val batteryStr
get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
/** true if the device was heard from recently */
val isOnline: Boolean
@ -255,14 +256,13 @@ data class NodeInfo(
fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist ->
when {
dist == 0 -> null // same point
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 ->
"%.0f m".format(dist.toDouble())
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 -> "$dist m"
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 ->
"%.1f km".format(dist / 1000.0)
"${(dist / 100).toDouble() / 10.0} km"
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 ->
"%.0f ft".format(dist.toDouble() * 3.281)
"${(dist.toDouble() * 3.281).toInt()} ft"
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 ->
"%.1f mi".format(dist / 1609.34)
"${(dist / 160.9).toInt() / 10.0} mi"
else -> null
}
}

View file

@ -16,15 +16,15 @@
*/
package org.meshtastic.core.model.util
import android.util.Base64
import okio.ByteString
import okio.ByteString.Companion.toByteString
import okio.ByteString.Companion.decodeBase64
fun ByteString.encodeToString(): String = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP)
fun ByteString.encodeToString(): String = base64()
/**
* Decodes a Base64 string into a [ByteString].
*
* @throws IllegalArgumentException if the string is not valid Base64.
*/
fun String.base64ToByteString(): ByteString = Base64.decode(this, Base64.NO_WRAP).toByteString()
fun String.base64ToByteString(): ByteString =
decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string: $this")

View file

@ -16,8 +16,6 @@
*/
package org.meshtastic.core.model.util
import android.os.Parcel
import kotlinx.parcelize.Parceler
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.SerialDescriptor
@ -25,6 +23,8 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParceler
/** Serializer for Okio [ByteString] using kotlinx.serialization */
object ByteStringSerializer : KSerializer<ByteString> {
@ -40,10 +40,10 @@ object ByteStringSerializer : KSerializer<ByteString> {
}
/** Parceler for Okio [ByteString] for Android Parcelable support */
object ByteStringParceler : Parceler<ByteString?> {
override fun create(parcel: Parcel): ByteString? = parcel.createByteArray()?.toByteString()
object ByteStringParceler : CommonParceler<ByteString?> {
override fun create(parcel: CommonParcel): ByteString? = parcel.createByteArray()?.toByteString()
override fun ByteString?.write(parcel: Parcel, flags: Int) {
override fun ByteString?.write(parcel: CommonParcel, flags: Int) {
parcel.writeByteArray(this?.toByteArray())
}
}

View file

@ -14,31 +14,24 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.core.model.util
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.util.Base64
import co.touchlab.kermit.Logger
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig
import java.net.MalformedURLException
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
/**
* Return a [ChannelSet] that represents the ChannelSet encoded by the URL.
*
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
* @throws MalformedMeshtasticUrlException when not recognized as a valid Meshtastic URL
*/
@Throws(MalformedURLException::class)
fun Uri.toChannelSet(): ChannelSet {
@Throws(MalformedMeshtasticUrlException::class)
fun CommonUri.toChannelSet(): ChannelSet {
val h = host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
@ -46,13 +39,16 @@ fun Uri.toChannelSet(): ChannelSet {
val isCorrectPath = segments.any { it.equals("e", ignoreCase = true) }
if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
throw MalformedMeshtasticUrlException("Not a valid Meshtastic URL: ${toString().take(40)}")
}
// Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment.
// This gracefully handles those cases until the newer version are generally available/used.
val fragmentBytes = Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)
val url = ChannelSet.ADAPTER.decode(fragmentBytes.toByteString())
val fragmentBase64 = fragment!!.substringBefore('?').replace('-', '+').replace('_', '/')
val fragmentBytes =
fragmentBase64.decodeBase64()
?: throw MalformedMeshtasticUrlException("Invalid Base64 in URL fragment: $fragmentBase64")
val url = ChannelSet.ADAPTER.decode(fragmentBytes)
val shouldAdd =
fragment?.substringAfter('?', "")?.takeUnless { it.isBlank() }?.equals("add=true")
?: getBooleanQueryParameter("add", false)
@ -85,35 +81,10 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
*
* @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes
*/
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri {
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): CommonUri {
val channelBytes = ChannelSet.ADAPTER.encode(this)
val enc = Base64.encodeToString(channelBytes, BASE64FLAGS)
val enc = channelBytes.toByteString().base64Url()
val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
val query = if (shouldAdd) "?add=true" else ""
return Uri.parse("$p$query#$enc")
}
fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try {
val multiFormatWriter = MultiFormatWriter()
val bitMatrix =
multiFormatWriter.encode(getChannelUrl(false, shouldAdd).toString(), BarcodeFormat.QR_CODE, 960, 960)
bitMatrix.toBitmap()
} catch (ex: Throwable) {
Logger.e { "URL was too complex to render as barcode" }
null
}
private fun BitMatrix.toBitmap(): Bitmap {
val width = width
val height = height
val pixels = IntArray(width * height)
for (y in 0 until height) {
val offset = y * width
for (x in 0 until width) {
pixels[offset + x] = if (get(x, y)) Color.BLACK else Color.WHITE
}
}
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
return bitmap
return CommonUri.parse("$p$query#$enc")
}

View file

@ -0,0 +1,19 @@
/*
* 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
expect val isDebug: Boolean

View file

@ -18,11 +18,11 @@
package org.meshtastic.core.model.util
import android.icu.util.LocaleData
import android.icu.util.ULocale
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.common.util.getSystemMeasurementSystem
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import java.util.Locale
@Suppress("MagicNumber")
enum class DistanceUnit(val symbol: String, val multiplier: Float, val system: Int) {
METER("m", multiplier = 1F, DisplayUnits.METRIC.value),
KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC.value),
@ -31,22 +31,10 @@ enum class DistanceUnit(val symbol: String, val multiplier: Float, val system: I
;
companion object {
fun getFromLocale(locale: Locale = Locale.getDefault()): DisplayUnits =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) {
LocaleData.MeasurementSystem.SI -> DisplayUnits.METRIC
else -> DisplayUnits.IMPERIAL
}
} else {
when (locale.country.uppercase(locale)) {
"US",
"LR",
"MM",
"GB",
-> DisplayUnits.IMPERIAL
else -> DisplayUnits.METRIC
}
}
fun getFromLocale(): DisplayUnits = when (getSystemMeasurementSystem()) {
MeasurementSystem.METRIC -> DisplayUnits.METRIC
MeasurementSystem.IMPERIAL -> DisplayUnits.IMPERIAL
}
}
}

View file

@ -18,7 +18,6 @@
package org.meshtastic.core.model.util
import org.meshtastic.core.model.BuildConfig
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Telemetry
@ -49,13 +48,14 @@ fun MeshPacket.toOneLineString(): String {
return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
}
fun Any.toPIIString() = if (!BuildConfig.DEBUG) {
fun Any.toPIIString() = if (!isDebug) {
"<PII?>"
} else {
this.toOneLineString()
}
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
@Suppress("MagicNumber")
fun ByteArray.toHexString() = joinToString("") { it.toUByte().toString(16).padStart(2, '0') }
private const val MPS_TO_KMPH = 3.6f
private const val KM_TO_MILES = 0.621371f

View file

@ -0,0 +1,24 @@
/*
* 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.latLongToMeter
import org.meshtastic.core.model.Position
/** @return distance in meters along the surface of the earth (ish) */
@Suppress("MagicNumber")
fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitude, a.longitude, b.latitude, b.longitude)

View file

@ -0,0 +1,20 @@
/*
* 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
/** Exception thrown when a Meshtastic URL cannot be parsed. */
class MalformedMeshtasticUrlException(message: String) : Exception(message)

View file

@ -0,0 +1,55 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.core.model.util
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshPacket
/**
* Utility class to map [MeshPacket] protobufs to [DataPacket] domain models.
*
* This class is platform-agnostic and can be used in shared logic.
*/
class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
/** Maps a [MeshPacket] to a [DataPacket], or returns null if the packet has no decoded data. */
fun toDataPacket(packet: MeshPacket): DataPacket? {
val decoded = packet.decoded ?: return null
return DataPacket(
from = nodeIdLookup.toNodeID(packet.from),
to = nodeIdLookup.toNodeID(packet.to),
time = packet.rx_time * 1000L,
id = packet.id,
dataType = decoded.portnum.value,
bytes = decoded.payload.toByteArray().toByteString(),
hopLimit = packet.hop_limit,
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.want_ack == true,
hopStart = packet.hop_start,
snr = packet.rx_snr,
rssi = packet.rx_rssi,
replyId = decoded.reply_id,
relayNode = packet.relay_node,
viaMqtt = packet.via_mqtt == true,
emoji = decoded.emoji,
transportMechanism = packet.transport_mechanism.value,
)
}
}

View file

@ -0,0 +1,23 @@
/*
* 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
/** Interface for looking up Node IDs from Node Numbers. */
interface NodeIdLookup {
/** Returns the Node ID (hex string) for the given [nodeNum]. */
fun toNodeID(nodeNum: Int): String
}

View file

@ -0,0 +1,19 @@
/*
* 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
expect fun platformRandomBytes(size: Int): ByteArray

View file

@ -14,32 +14,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions", "SwallowedException", "TooGenericExceptionCaught")
package org.meshtastic.core.model.util
import android.net.Uri
import android.util.Base64
import okio.ByteString
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
private const val BASE64FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
/**
* Return a [SharedContact] that represents the contact encoded by the URL.
*
* @throws MalformedURLException when not recognized as a valid Meshtastic URL
* @throws MalformedMeshtasticUrlException when not recognized as a valid Meshtastic URL
*/
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): SharedContact {
@Throws(MalformedMeshtasticUrlException::class)
fun CommonUri.toSharedContact(): SharedContact {
checkSharedContactUrl()
val data = fragment!!.substringBefore('?')
return decodeSharedContactData(data)
}
@Throws(MalformedURLException::class)
private fun Uri.checkSharedContactUrl() {
@Throws(MalformedMeshtasticUrlException::class)
private fun CommonUri.checkSharedContactUrl() {
val h = host?.lowercase() ?: ""
val isCorrectHost = h == MESHTASTIC_HOST || h == "www.$MESHTASTIC_HOST"
val segments = pathSegments
@ -47,41 +46,40 @@ private fun Uri.checkSharedContactUrl() {
val frag = fragment
if (frag.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
throw MalformedURLException(
throw MalformedMeshtasticUrlException(
"Not a valid Meshtastic URL: host=$h, segments=$segments, hasFragment=${!frag.isNullOrBlank()}",
)
}
}
@Throws(MalformedURLException::class)
@Suppress("ThrowsCount")
@Throws(MalformedMeshtasticUrlException::class)
private fun decodeSharedContactData(data: String): SharedContact {
val decodedBytes =
try {
// We use a more lenient decoding for the input to handle variations from different clients
Base64.decode(data, Base64.DEFAULT or Base64.URL_SAFE)
val sanitized = data.replace('-', '+').replace('_', '/')
sanitized.decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string")
} catch (e: IllegalArgumentException) {
val ex =
MalformedURLException(
"Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
)
ex.initCause(e)
throw ex
throw MalformedMeshtasticUrlException(
"Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
)
}
return try {
SharedContact.ADAPTER.decode(decodedBytes.toByteString())
} catch (e: java.io.IOException) {
val ex = MalformedURLException("Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}")
ex.initCause(e)
throw ex
SharedContact.ADAPTER.decode(decodedBytes)
} catch (e: Exception) {
throw MalformedMeshtasticUrlException(
"Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}",
)
}
}
/** Converts a [SharedContact] to its corresponding URI representation. */
fun SharedContact.getSharedContactUrl(): Uri {
fun SharedContact.getSharedContactUrl(): CommonUri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return Uri.parse("$CONTACT_URL_PREFIX$enc")
val enc = bytes.toByteString().base64Url()
return CommonUri.parse("$CONTACT_URL_PREFIX$enc")
}
/** Compares two [User] objects and returns a string detailing the differences. */
@ -130,4 +128,4 @@ fun userFieldsToString(user: User): String {
return fieldLines.joinToString("\n")
}
private fun ByteString.base64String(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()
private fun ByteString.base64String(): String = base64()

View file

@ -0,0 +1,24 @@
/*
* 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 kotlin.time.Duration.Companion.hours
private val ONLINE_WINDOW_HOURS = 2.hours
fun onlineTimeThreshold(): Int = (nowInstant - ONLINE_WINDOW_HOURS).epochSeconds.toInt()

View file

@ -16,8 +16,8 @@
*/
package org.meshtastic.core.model.util
import android.net.Uri
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@ -29,7 +29,11 @@ import org.meshtastic.proto.SharedContact
* @param onContact Callback if the URI is a Shared Contact.
* @return True if the URI was handled (matched a supported path), false otherwise.
*/
fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri) -> Unit = {}): Boolean {
fun handleMeshtasticUri(
uri: CommonUri,
onChannel: (CommonUri) -> Unit = {},
onContact: (CommonUri) -> Unit = {},
): Boolean {
val h = uri.host ?: ""
val isCorrectHost =
h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
@ -56,7 +60,7 @@ fun handleMeshtasticUri(uri: Uri, onChannel: (Uri) -> Unit = {}, onContact: (Uri
* @param onContact Callback when successfully parsed as a [SharedContact].
* @param onInvalid Callback when parsing fails or the URI is not a Meshtastic URL.
*/
fun Uri.dispatchMeshtasticUri(
fun CommonUri.dispatchMeshtasticUri(
onChannel: (ChannelSet) -> Unit,
onContact: (SharedContact) -> Unit,
onInvalid: () -> Unit,

View file

@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable DataPacket;

View file

@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable MeshUser;

View file

@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable MyNodeInfo;

View file

@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable NodeInfo;

View file

@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable Position;

View file

@ -1,83 +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/>.
*/
@file:Suppress("MatchingDeclarationName")
package org.meshtastic.core.model.util
import android.annotation.SuppressLint
import org.meshtastic.core.model.Position
import java.util.Locale
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@SuppressLint("PropertyNaming")
object GPSFormat {
fun toDec(latitude: Double, longitude: Double): String =
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
}
private const val EARTH_RADIUS_METERS = 6371e3
/** @return distance in meters along the surface of the earth (ish) */
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
val lat1 = Math.toRadians(latitudeA)
val lon1 = Math.toRadians(longitudeA)
val lat2 = Math.toRadians(latitudeB)
val lon2 = Math.toRadians(longitudeB)
val dLat = lat2 - lat1
val dLon = lon2 - lon1
val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
val c = 2 * asin(sqrt(a))
return EARTH_RADIUS_METERS * c
}
// Same as above, but takes Mesh Position proto.
@Suppress("MagicNumber")
fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitude, a.longitude, b.latitude, b.longitude)
/**
* Computes the bearing in degrees between two points on Earth.
*
* @param lat1 Latitude of the first point
* @param lon1 Longitude of the first point
* @param lat2 Latitude of the second point
* @param lon2 Longitude of the second point
* @return Bearing between the two points in degrees. A value of 0 means due north.
*/
@Suppress("MagicNumber")
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val lat1Rad = Math.toRadians(lat1)
val lon1Rad = Math.toRadians(lon1)
val lat2Rad = Math.toRadians(lat2)
val lon2Rad = Math.toRadians(lon2)
val dLon = lon2Rad - lon1Rad
val y = sin(dLon) * cos(lat2Rad)
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
val bearing = Math.toDegrees(atan2(y, x))
return (bearing + 360) % 360
}