feat: introduce Desktop target and expand Kotlin Multiplatform (KMP) architecture (#4761)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-12 16:14:49 -05:00 committed by GitHub
parent f4364cff9a
commit ac6bb5479b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
386 changed files with 17089 additions and 4590 deletions

View file

@ -24,7 +24,6 @@ import java.text.DateFormat
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
private val DAY_DURATION = 24.hours
@ -48,51 +47,6 @@ fun getShortDate(time: Long): String? {
}
}
/**
* Returns a short string representing the time if it's within the last 24 hours, otherwise returns a combined short
* date/time string.
*
* @param time The time in milliseconds
* @return Formatted date/time string
*/
fun getShortDateTime(time: Long): String {
val instant = time.toInstant()
val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
} else {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(instant.toDate())
}
}
/**
* Formats a duration in seconds as a human-readable uptime string (e.g., "1d 2h 3m 4s").
*
* @param seconds The duration in seconds.
* @return A formatted uptime string.
*/
fun formatUptime(seconds: Int): String = formatUptime(seconds.toLong())
/**
* Formats a duration in seconds as a human-readable uptime string (e.g., "1d 2h 3m 4s").
*
* @param seconds The duration in seconds.
* @return A formatted uptime string.
*/
private fun formatUptime(seconds: Long): String {
if (seconds == 0L) return "0s"
return seconds.seconds.toComponents { days, hours, minutes, secs, _ ->
listOfNotNull(
"${days}d".takeIf { days > 0 },
"${hours}h".takeIf { hours > 0 },
"${minutes}m".takeIf { minutes > 0 },
"${secs}s".takeIf { secs > 0 },
)
.joinToString(" ")
}
}
/**
* Calculates the remaining mute time in days and hours.
*

View file

@ -80,7 +80,6 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
ModemPreset.LONG_MODERATE -> "LongMod"
ModemPreset.VERY_LONG_SLOW -> "VLongSlow"
ModemPreset.LONG_TURBO -> "LongTurbo"
else -> "Invalid"
}
} else {
"Custom"

View file

@ -75,7 +75,7 @@ internal fun LoRaConfig.channelNum(primaryName: String): Int = when {
}
internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
if ((override_frequency ?: 0f) != 0f) return (override_frequency ?: 0f) + (frequency_offset ?: 0f)
if (override_frequency != 0f) return override_frequency + frequency_offset
val regionInfo = RegionInfo.fromRegionCode(region)
return if (regionInfo != null) {
(regionInfo.freqStart + bandwidth(regionInfo) / 2) + (channelNum - 1) * bandwidth(regionInfo)

View file

@ -0,0 +1,36 @@
/*
* 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
/** Represent the different ways a device can connect to the client. */
enum class DeviceType {
BLE,
TCP,
USB,
;
companion object {
fun fromAddress(address: String): DeviceType? = when (address.firstOrNull()) {
'x' -> BLE
's' -> USB
't' -> TCP
'm' -> USB // Treat mock as USB for UI purposes
'n' -> null
else -> null
}
}
}

View file

@ -0,0 +1,68 @@
/*
* 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 co.touchlab.kermit.Logger
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.Position
/**
* Represents a log entry in shared repository/domain code.
*
* Logs are used for auditing radio traffic, telemetry history, and debugging.
*/
@Suppress("EmptyCatchBlock", "SwallowedException", "ConstructorParameterNaming")
data class MeshLog(
val uuid: String,
val message_type: String,
val received_date: Long,
val raw_message: String,
val fromNum: Int = 0,
val portNum: Int = 0,
val fromRadio: FromRadio = FromRadio(),
) {
val meshPacket = fromRadio.packet
val nodeInfo: NodeInfo?
get() = fromRadio.node_info
val myNodeInfo: MyNodeInfo?
get() = fromRadio.my_info
val position: Position?
get() =
fromRadio.packet?.decoded?.payload?.let {
if (fromRadio.packet?.decoded?.portnum == org.meshtastic.proto.PortNum.POSITION_APP) {
Position.ADAPTER.decodeOrNull(it, Logger)
} else {
null
}
} ?: nodeInfo?.position
companion object {
/**
* The node number used to represent the local node in the logs.
*
* Using 0 instead of the actual node number ensures log continuity even if the radio hardware or local ID
* changes.
*/
const val NODE_NUM_LOCAL = 0
}
}

View file

@ -86,7 +86,7 @@ data class Node(
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
get() = (publicKey ?: user.public_key).size > 0
val mismatchKey
get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING
@ -184,8 +184,7 @@ data class Node(
)
}
private fun Paxcount.getDisplayString() =
"PAX: ${(ble ?: 0) + (wifi ?: 0)} (B:${ble ?: 0}/W:${wifi ?: 0})".takeIf { (ble ?: 0) != 0 || (wifi ?: 0) != 0 }
private fun Paxcount.getDisplayString() = "PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
environmentMetrics.getDisplayStrings(isFahrenheit)

View file

@ -50,7 +50,7 @@ data class MeshUser(
/** Create our model object from a protobuf. */
constructor(
p: org.meshtastic.proto.User,
) : this(p.id, p.long_name ?: "", p.short_name ?: "", p.hw_model, p.is_licensed, p.role.value)
) : this(p.id, p.long_name, p.short_name, p.hw_model, p.is_licensed, p.role.value)
/**
* a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null
@ -100,10 +100,10 @@ data class Position(
degD(position.longitude_i ?: 0),
position.altitude ?: 0,
if (position.time != 0) position.time else defaultTime,
position.sats_in_view ?: 0,
position.sats_in_view,
position.ground_speed ?: 0,
position.ground_track ?: 0,
position.precision_bits ?: 0,
position.precision_bits,
)
// / @return distance in meters to some other node (or null if unknown)

View file

@ -0,0 +1,48 @@
/*
* 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 kotlin.time.Duration.Companion.seconds
/**
* Returns a short string representing the time if it's within the last 24 hours, otherwise returns a combined short
* date/time string.
*
* @param time The time in milliseconds
* @return Formatted date/time string
*/
expect fun getShortDateTime(time: Long): String
/**
* Formats a duration in seconds as a human-readable uptime string (e.g., "1d 2h 3m 4s").
*
* @param seconds The duration in seconds.
* @return A formatted uptime string.
*/
fun formatUptime(seconds: Int): String {
val secs = seconds.toLong()
if (secs == 0L) return "0s"
return secs.seconds.toComponents { days, hours, minutes, s, _ ->
listOfNotNull(
"${days}d".takeIf { days > 0 },
"${hours}h".takeIf { hours > 0 },
"${minutes}m".takeIf { minutes > 0 },
"${s}s".takeIf { s > 0 },
)
.joinToString(" ")
}
}

View file

@ -16,4 +16,11 @@
*/
package org.meshtastic.core.model.util
expect val isDebug: Boolean
/**
* Whether the app is running in debug mode.
*
* This is a compile-time constant for the shared module. For runtime debug detection, use
* [org.meshtastic.core.common.BuildConfigProvider.isDebug] from DI instead.
*/
@Suppress("ktlint:standard:property-naming", "TopLevelPropertyNaming")
const val isDebug: Boolean = false

View file

@ -16,4 +16,7 @@
*/
package org.meshtastic.core.model.util
actual val isDebug: Boolean = false
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
expect object SfppHasher {
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
}

View file

@ -27,4 +27,5 @@ object TimeConstants {
val TWO_DAYS = 2.days
const val HOURS_PER_DAY = 24
const val MS_PER_SEC = 1000L
}

View file

@ -0,0 +1,43 @@
/*
* 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 combined short
* date/time string.
*
* @param time The time in milliseconds
* @return Formatted date/time string
*/
actual fun getShortDateTime(time: Long): String {
val instant = time.toInstant()
val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
} else {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(instant.toDate())
}
}

View file

@ -20,11 +20,11 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
object SfppHasher {
actual object SfppHasher {
private const val HASH_SIZE = 16
private const val INT_BYTES = 4
fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
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())