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
144 changed files with 1460 additions and 664 deletions

View File

@@ -77,6 +77,15 @@ This file serves as a comprehensive guide for AI agents and developers working o
- **`fdroid`**: FOSS version. **Strictly segregate sensitive data** (Crashlytics, Firebase, etc.) out of this flavor.
- **Task Example:** `./gradlew assembleFdroidDebug`
### G. Kotlin Multiplatform (KMP) & Decoupling
- **Goal:** We are actively moving logic and models from Android-specific modules to KMP modules (`core:common`, `core:model`, `core:proto`) to support future cross-platform expansion.
- **Domain Models:** Always place domain models (Data Classes, Enums) in `commonMain` of the respective module.
- **Parceling:**
- Use the platform-agnostic `CommonParcelable` and `CommonParcelize` from `core:common`.
- Avoid direct imports of `android.os.Parcelable` or `kotlinx.parcelize.Parcelize` in `commonMain`.
- **Platform Abstractions:** Use `expect`/`actual` for platform-specific logic (e.g., `DateFormatter`, `RandomUtils`, `BuildUtils`).
- **AIDL Compatibility:** AIDL parcelable declarations for models moved to `commonMain` should be relocated to `:core:api` to ensure proper export to consumer modules.
## 4. Quality Assurance
### A. Code Style (Spotless)

View File

@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
@@ -58,6 +59,7 @@ open class MeshUtilApplication :
override fun onCreate() {
super.onCreate()
ContextServices.app = this
initializeMaps(this)
// Schedule periodic MeshLog cleanup

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,22 +14,25 @@
* 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 com.geeksville.mesh.navigation
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.contact.AdaptiveContactsScreen
import com.geeksville.mesh.ui.sharing.ShareScreen
import com.geeksville.mesh.model.UIViewModel
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@Suppress("LongMethod")
fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
@@ -37,7 +40,19 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
) {
AdaptiveContactsScreen(navController = navController, scrollToTopEvents = scrollToTopEvents)
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
AdaptiveContactsScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
)
}
composable<ContactsRoutes.Messages>(
deepLinks =
@@ -49,9 +64,18 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
),
) { backStackEntry ->
val args = backStackEntry.toRoute<ContactsRoutes.Messages>()
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
AdaptiveContactsScreen(
navController = navController,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = args.contactKey,
initialMessage = args.message,
)

View File

@@ -67,6 +67,7 @@ constructor(
get() = newNodes.size
private var rawMyNodeInfo: MyNodeInfo? = null
private var lastMetadata: DeviceMetadata? = null
private var newMyNodeInfo: MyNodeEntity? = null
private var myNodeInfo: MyNodeEntity? = null
@@ -79,12 +80,20 @@ constructor(
}
private fun handleConfigOnlyComplete() {
Logger.i { "Config-only complete" }
Logger.i { "Config-only complete (Stage 1)" }
if (newMyNodeInfo == null) {
Logger.e { "Did not receive a valid config - newMyNodeInfo is null" }
Logger.w {
"newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
}
regenMyNodeInfo(lastMetadata)
}
val finalizedInfo = newMyNodeInfo
if (finalizedInfo == null) {
Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
} else {
myNodeInfo = newMyNodeInfo
Logger.i { "myNodeInfo committed successfully" }
myNodeInfo = finalizedInfo
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.onRadioConfigLoaded()
}
@@ -92,6 +101,7 @@ constructor(
delay(wantConfigDelay)
sendHeartbeat()
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.startNodeInfoOnly()
}
}
@@ -106,7 +116,7 @@ constructor(
}
private fun handleNodeInfoComplete() {
Logger.i { "NodeInfo complete" }
Logger.i { "NodeInfo complete (Stage 2)" }
val entities =
newNodes.map { info ->
nodeManager.installNodeInfo(info, withBroadcast = false)
@@ -134,8 +144,8 @@ constructor(
fun handleMyInfo(myInfo: MyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
rawMyNodeInfo = myInfo
nodeManager.myNodeNum = myInfo.my_node_num ?: 0
regenMyNodeInfo()
nodeManager.myNodeNum = myInfo.my_node_num
regenMyNodeInfo(lastMetadata)
scope.handledLaunch {
radioConfigRepository.clearChannelSet()
@@ -145,7 +155,8 @@ constructor(
}
fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received" }
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
lastMetadata = metadata
regenMyNodeInfo(metadata)
}
@@ -153,36 +164,43 @@ constructor(
newNodes.add(info)
}
private fun regenMyNodeInfo(metadata: DeviceMetadata? = DeviceMetadata()) {
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
val mi =
with(myInfo) {
MyNodeEntity(
myNodeNum = my_node_num ?: 0,
model =
when (val hwModel = metadata?.hw_model) {
null,
HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = metadata?.firmware_version,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
messageTimeoutMsec = 300000,
minAppVersion = min_app_version ?: 0,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
deviceId = device_id?.utf8() ?: "",
pioEnv = if (myInfo.pio_env.isNullOrEmpty()) null else myInfo.pio_env,
)
try {
val mi =
with(myInfo) {
MyNodeEntity(
myNodeNum = my_node_num ?: 0,
model =
when (val hwModel = metadata?.hw_model) {
null,
HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
couldUpdate = false,
shouldUpdate = false,
currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL,
messageTimeoutMsec = 300000,
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
deviceId = device_id.utf8(),
pioEnv = myInfo.pio_env.ifEmpty { null },
)
}
if (metadata != null && metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
}
if (metadata != null && metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
newMyNodeInfo = mi
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Failed to regenMyNodeInfo" }
}
newMyNodeInfo = mi
} else {
Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
}
}
}

View File

@@ -35,13 +35,15 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected_count
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.AdminMessage
@@ -77,12 +79,16 @@ constructor(
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
fun start(scope: CoroutineScope) {
this.scope = scope
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
// Ensure notification title and content stay in sync with state changes
connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
nodeRepository.myNodeInfo
.onEach { myNodeEntity ->
locationRequestsJob?.cancel()
@@ -122,11 +128,21 @@ constructor(
}
private fun onConnectionChanged(c: ConnectionState) {
if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return
Logger.d { "onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c" }
val current = connectionStateHolder.connectionState.value
if (current == c) return
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
return
}
Logger.i { "onConnectionChanged: $current -> $c" }
sleepTimeout?.cancel()
sleepTimeout = null
handshakeTimeout?.cancel()
handshakeTimeout = null
when (c) {
is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
@@ -134,19 +150,33 @@ constructor(
is ConnectionState.DeviceSleep -> handleDeviceSleep()
is ConnectionState.Disconnected -> handleDisconnected()
}
updateStatusNotification()
}
private fun handleConnected() {
// The service state remains 'Connecting' until config is fully loaded
if (connectionStateHolder.connectionState.value == ConnectionState.Disconnected) {
if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
connectionStateHolder.setState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
Logger.d { "Starting connect" }
Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
startConfigOnly()
// Guard against handshake stalls
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
Logger.w { "Handshake stall detected! Retrying Stage 1." }
startConfigOnly()
// Recursive timeout for one more try
delay(HANDSHAKE_TIMEOUT)
if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Resetting connection." }
onConnectionChanged(ConnectionState.Disconnected)
}
}
}
}
private fun handleDeviceSleep() {
@@ -215,6 +245,9 @@ constructor(
}
fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
@@ -236,7 +269,9 @@ constructor(
}
}
updateStatusNotification()
// Request immediate LocalStats and DeviceMetrics update on connection with proper request IDs
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
}
private fun reportConnection() {
@@ -258,8 +293,7 @@ constructor(
val summary =
when (connectionStateHolder.connectionState.value) {
is ConnectionState.Connected ->
getString(Res.string.connected_count)
.format(nodeManager.nodeDBbyNodeNum.values.count { it.isOnline })
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is ConnectionState.Connecting -> getString(Res.string.connecting)
@@ -271,6 +305,7 @@ constructor(
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_NONCE = 69421
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 10.seconds
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"

View File

@@ -16,40 +16,17 @@
*/
package com.geeksville.mesh.service
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshPacket
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.model.util.MeshDataMapper as CommonMeshDataMapper
@Singleton
class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManager) {
fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
nodeManager.nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
}
private val commonMapper = CommonMeshDataMapper(nodeManager)
fun toDataPacket(packet: MeshPacket): DataPacket? {
val decoded = packet.decoded ?: return null
return DataPacket(
from = toNodeID(packet.from),
to = 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,
)
}
fun toNodeID(n: Int): String = nodeManager.toNodeID(n)
fun toDataPacket(packet: MeshPacket): DataPacket? = commonMapper.toDataPacket(packet)
}

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@@ -54,7 +55,7 @@ constructor(
private val nodeRepository: NodeRepository?,
private val serviceBroadcasts: MeshServiceBroadcasts?,
private val serviceNotifications: MeshServiceNotifications?,
) {
) : NodeIdLookup {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val nodeDBbyNodeNum = ConcurrentHashMap<Int, NodeEntity>()
@@ -260,9 +261,9 @@ constructor(
return hasExistingUser && isDefaultName && isDefaultHwModel
}
fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) {
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n)
nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
}
}

View File

@@ -44,6 +44,7 @@ import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
@@ -56,6 +57,16 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_battery
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
import org.meshtastic.core.resources.local_stats_dropped
import org.meshtastic.core.resources.local_stats_nodes
import org.meshtastic.core.resources.local_stats_noise
import org.meshtastic.core.resources.local_stats_relays
import org.meshtastic.core.resources.local_stats_traffic
import org.meshtastic.core.resources.local_stats_uptime
import org.meshtastic.core.resources.local_stats_utilization
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.mark_as_read
@@ -112,6 +123,7 @@ constructor(
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
private const val BULLET = ""
}
/**
@@ -270,35 +282,59 @@ constructor(
notificationManager.createNotificationChannel(channel)
}
var cachedTelemetry: Telemetry? = null
var cachedLocalStats: LocalStats? = null
var nextStatsUpdateMillis: Long = 0
var cachedMessage: String? = null
private var cachedDeviceMetrics: DeviceMetrics? = null
private var cachedLocalStats: LocalStats? = null
private var nextStatsUpdateMillis: Long = 0
private var cachedMessage: String? = null
// region Public Notification Methods
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification {
val hasLocalStats = telemetry?.local_stats != null
val hasDeviceMetrics = telemetry?.device_metrics != null
// Update caches if telemetry is provided
telemetry?.let { t ->
t.local_stats?.let { stats ->
cachedLocalStats = stats
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
}
t.device_metrics?.let { metrics -> cachedDeviceMetrics = metrics }
}
// Seeding from database if caches are still null (e.g. on restart or reconnection)
if (cachedLocalStats == null || cachedDeviceMetrics == null) {
val repo = nodeRepository.get()
val myNodeNum = repo.myNodeInfo.value?.myNodeNum
if (myNodeNum != null) {
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
// and we only do this once if the cache is empty.
val nodes = runBlocking { repo.getNodeDBbyNum().first() }
nodes[myNodeNum]?.let { entity ->
if (cachedDeviceMetrics == null) {
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
}
if (cachedLocalStats == null) {
cachedLocalStats = entity.deviceTelemetry.local_stats
}
}
}
}
val stats = cachedLocalStats
val metrics = cachedDeviceMetrics
val message =
when {
hasLocalStats -> {
val localStatsMessage = telemetry?.local_stats?.formatToString()
cachedTelemetry = telemetry
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
localStatsMessage
}
cachedTelemetry == null && hasDeviceMetrics -> {
val deviceMetricsMessage = telemetry?.device_metrics?.formatToString()
if (cachedLocalStats == null) {
cachedTelemetry = telemetry
}
nextStatsUpdateMillis = nowMillis
deviceMetricsMessage
}
stats != null -> stats.formatToString(metrics?.battery_level)
metrics != null -> metrics.formatToString()
else -> null
}
cachedMessage = message ?: cachedMessage ?: getString(Res.string.no_local_stats)
// Only update cachedMessage if we have something new, otherwise keep what we have.
// Fallback to "No Stats Available" only if we truly have nothing.
if (message != null) {
cachedMessage = message
} else if (cachedMessage == null) {
cachedMessage = getString(Res.string.no_local_stats)
}
val notification =
createServiceStateNotification(
@@ -471,7 +507,8 @@ constructor(
.setShowWhen(true)
message?.let {
builder.setContentText(it)
// First line of message is used for collapsed view, ensure it doesn't have a bullet
builder.setContentText(it.substringBefore("\n").removePrefix(BULLET))
builder.setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
@@ -633,7 +670,7 @@ constructor(
private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification {
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
val title = getString(Res.string.low_battery_title).format(node.shortName)
val batteryLevel = node.deviceTelemetry?.device_metrics?.battery_level ?: 0
val batteryLevel = node.deviceMetrics?.battery_level ?: 0
val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel)
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
@@ -811,23 +848,48 @@ constructor(
return IconCompat.createWithBitmap(bitmap)
}
// endregion
// region Extension Functions (Localized)
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
val parts = mutableListOf<String>()
batteryLevel?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
parts.add(BULLET + getString(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes))
parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(uptime_seconds)))
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization, air_util_tx))
// Traffic Stats
if (num_packets_tx > 0 || num_packets_rx > 0) {
parts.add(BULLET + getString(Res.string.local_stats_traffic, num_packets_tx, num_packets_rx, num_rx_dupe))
}
if (num_tx_relay > 0) {
parts.add(BULLET + getString(Res.string.local_stats_relays, num_tx_relay, num_tx_relay_canceled))
}
// Diagnostic Fields
val diagnosticParts = mutableListOf<String>()
if (noise_floor != 0) diagnosticParts.add(getString(Res.string.local_stats_noise, noise_floor))
if (num_packets_rx_bad > 0) diagnosticParts.add(getString(Res.string.local_stats_bad, num_packets_rx_bad))
if (num_tx_dropped > 0) diagnosticParts.add(getString(Res.string.local_stats_dropped, num_tx_dropped))
if (diagnosticParts.isNotEmpty()) {
parts.add(
BULLET + getString(Res.string.local_stats_diagnostics_prefix, diagnosticParts.joinToString(" | ")),
)
}
return parts.joinToString("\n")
}
private fun DeviceMetrics.formatToString(): String {
val parts = mutableListOf<String>()
battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(it))) }
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f))
return parts.joinToString("\n")
}
// endregion
}
// Extension function to format LocalStats into a readable string.
private fun LocalStats.formatToString(): String {
val parts = mutableListOf<String>()
parts.add("Uptime: ${formatUptime(uptime_seconds)}")
parts.add("ChUtil: %.2f%%".format(channel_utilization))
parts.add("AirUtilTX: %.2f%%".format(air_util_tx))
return parts.joinToString("\n")
}
private fun DeviceMetrics.formatToString(): String {
val parts = mutableListOf<String>()
battery_level?.let { parts.add("Battery Level: $it") }
uptime_seconds?.let { parts.add("Uptime: ${formatUptime(it)}") }
channel_utilization?.let { parts.add("ChUtil: %.2f%%".format(it)) }
air_util_tx?.let { parts.add("AirUtilTX: %.2f%%".format(it)) }
return parts.joinToString("\n")
}

View File

@@ -507,42 +507,47 @@ private fun VersionChecks(viewModel: UIViewModel) {
},
)
} else {
myFirmwareVersion?.let { fwVersion ->
val curVer = DeviceVersion(fwVersion)
Logger.i {
"[FW_CHECK] Firmware version comparison - " +
"device: $curVer (raw: $fwVersion), " +
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
"min: ${MeshService.minDeviceVersion}"
}
myFirmwareVersion
?.takeIf { it.isNotBlank() }
?.let { fwVersion ->
val curVer = DeviceVersion(fwVersion)
Logger.i {
"[FW_CHECK] Firmware version comparison - " +
"device: $curVer (raw: $fwVersion), " +
"absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
"min: ${MeshService.minDeviceVersion}"
}
if (curVer < MeshService.absoluteMinDeviceVersion) {
Logger.w {
"[FW_CHECK] Firmware too old - " +
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
if (curVer < MeshService.absoluteMinDeviceVersion) {
Logger.w {
"[FW_CHECK] Firmware too old - " +
"device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
}
val title = getString(Res.string.firmware_too_old)
val message = getString(Res.string.firmware_old)
viewModel.showAlert(
title = title,
html = message,
onConfirm = {
val service = viewModel.meshService ?: return@showAlert
MeshService.changeDeviceAddress(context, service, "n")
},
)
} else if (curVer < MeshService.minDeviceVersion) {
Logger.w {
"[FW_CHECK] Firmware should update - " +
"device: $curVer < min: ${MeshService.minDeviceVersion}"
}
val title = getString(Res.string.should_update_firmware)
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
viewModel.showAlert(title = title, message = message, onConfirm = {})
} else {
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
}
val title = getString(Res.string.firmware_too_old)
val message = getString(Res.string.firmware_old)
viewModel.showAlert(
title = title,
html = message,
onConfirm = {
val service = viewModel.meshService ?: return@showAlert
MeshService.changeDeviceAddress(context, service, "n")
},
)
} else if (curVer < MeshService.minDeviceVersion) {
Logger.w {
"[FW_CHECK] Firmware should update - " +
"device: $curVer < min: ${MeshService.minDeviceVersion}"
}
val title = getString(Res.string.should_update_firmware)
val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
viewModel.showAlert(title = title, message = message, onConfirm = {})
} else {
Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
}
} ?: run { Logger.w { "[FW_CHECK] Firmware version is null despite myNodeInfo being present" } }
?: run {
Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
}
}
} ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
} else {

View File

@@ -16,9 +16,6 @@
*/
package com.geeksville.mesh.ui.connections
import android.net.InetAddresses
import android.os.Build
import android.util.Patterns
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -87,15 +84,6 @@ import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
false
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
Patterns.IP_ADDRESS.matcher(this).matches() || Patterns.DOMAIN_NAME.matcher(this).matches()
} else {
InetAddresses.isNumericAddress(this) || Patterns.DOMAIN_NAME.matcher(this).matches()
}
/**
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
* displays connection status.
@@ -180,8 +168,9 @@ fun ConnectionsScreen(
val uiState =
when {
connectionState.isConnected() && ourNode != null -> 2
connectionState == ConnectionState.Connecting ||
(connectionState == ConnectionState.Disconnected && selectedDevice != "n") -> 1
connectionState.isConnected() ||
connectionState == ConnectionState.Connecting ||
selectedDevice != NO_DEVICE_SELECTED -> 1
else -> 0
}

View File

@@ -118,14 +118,17 @@ fun CurrentlyConnectedInfo(
Column(modifier = Modifier.weight(1f, fill = true)) {
Text(text = node.user.long_name ?: "", style = MaterialTheme.typography.titleMedium)
node.metadata?.firmware_version?.let { firmwareVersion ->
Text(
text = stringResource(Res.string.firmware_version, firmwareVersion),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
node.metadata
?.firmware_version
?.takeIf { it.isNotBlank() }
?.let { firmwareVersion ->
Text(
text = stringResource(Res.string.firmware_version, firmwareVersion),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View File

@@ -50,9 +50,9 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.ui.connections.ScannerViewModel
import com.geeksville.mesh.ui.connections.isValidAddress
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address

View File

@@ -16,6 +16,7 @@
*/
package com.geeksville.mesh.ui.sharing
import android.net.Uri
import android.os.RemoteException
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -68,6 +69,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
@@ -299,10 +301,10 @@ fun ChannelScreen(
@Composable
private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) {
val url = channelSet.getChannelUrl(shouldAddChannel)
val commonUri = channelSet.getChannelUrl(shouldAddChannel)
QrDialog(
title = stringResource(Res.string.share_channels_qr),
uri = url,
uri = commonUri.toPlatformUri() as Uri,
qrCode = channelSet.qrCode(shouldAddChannel),
onDismiss = onDismiss,
)

View File

@@ -10,12 +10,12 @@
<path
android:pathData="m17.5564,11.8482 l-5.208,7.6376 -1.5217,-1.0377 5.9674,-8.7512c0.1714,-0.2513 0.4558,-0.4019 0.76,-0.4022 0.3042,-0.0003 0.5889,0.1497 0.7608,0.4008l5.9811,8.7374 -1.5199,1.0404z"
android:strokeLineJoin="round"
android:fillColor="@android:color/black"
android:fillColor="#ff000000"
android:fillType="evenOdd"/>
<path
android:pathData="m5.854,19.4956 l6.3707,-9.3423 -1.5749,-1.0739 -6.3707,9.3423z"
android:strokeLineJoin="round"
android:fillColor="@android:color/black"
android:fillColor="#ff000000"
android:fillType="evenOdd"/>
</group>
</vector>

View File

@@ -24,7 +24,6 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
@@ -42,6 +41,7 @@ class MeshDataMapperTest {
@Test
fun `toNodeID resolves broadcast correctly`() {
every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
assertEquals(DataPacket.ID_BROADCAST, mapper.toNodeID(DataPacket.NODENUM_BROADCAST))
}
@@ -49,9 +49,7 @@ class MeshDataMapperTest {
fun `toNodeID resolves known node correctly`() {
val nodeNum = 1234
val nodeId = "!1234abcd"
val nodeEntity = mockk<NodeEntity>()
every { nodeEntity.user.id } returns nodeId
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns nodeEntity
every { nodeManager.toNodeID(nodeNum) } returns nodeId
assertEquals(nodeId, mapper.toNodeID(nodeNum))
}
@@ -59,9 +57,10 @@ class MeshDataMapperTest {
@Test
fun `toNodeID resolves unknown node to default ID`() {
val nodeNum = 1234
every { nodeManager.nodeDBbyNodeNum[nodeNum] } returns null
val nodeId = DataPacket.nodeNumToDefaultId(nodeNum)
every { nodeManager.toNodeID(nodeNum) } returns nodeId
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), mapper.toNodeID(nodeNum))
assertEquals(nodeId, mapper.toNodeID(nodeNum))
}
@Test
@@ -74,9 +73,8 @@ class MeshDataMapperTest {
fun `toDataPacket maps basic fields correctly`() {
val nodeNum = 1234
val nodeId = "!1234abcd"
val nodeEntity = mockk<NodeEntity>()
every { nodeEntity.user.id } returns nodeId
every { nodeManager.nodeDBbyNodeNum[any()] } returns nodeEntity
every { nodeManager.toNodeID(nodeNum) } returns nodeId
every { nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
val proto =
MeshPacket(
@@ -113,7 +111,7 @@ class MeshDataMapperTest {
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data())
every { nodeManager.nodeDBbyNodeNum[any()] } returns null
every { nodeManager.toNodeID(any()) } returns "any"
val result = mapper.toDataPacket(proto)
assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)

View File

@@ -15,10 +15,10 @@ dependencies {
// The core AIDL interface and Intent constants
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:v2.x.x")
// Data models (DataPacket, MeshUser, NodeInfo, etc.)
// Data models (DataPacket, MeshUser, NodeInfo, etc.) - Kotlin Multiplatform
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.x.x")
// Protobuf definitions (PortNum, Telemetry, etc.)
// Protobuf definitions (PortNum, Telemetry, etc.) - Kotlin Multiplatform
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:v2.x.x")
}
```

View File

@@ -15,7 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins { alias(libs.plugins.meshtastic.kmp.library) }
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.kotlin.parcelize)
}
kotlin {
@Suppress("UnstableApiUsage")

View File

@@ -17,6 +17,7 @@
package org.meshtastic.core.common
import android.Manifest
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
@@ -25,6 +26,11 @@ import android.location.LocationManager
import android.os.Build
import androidx.core.content.ContextCompat
/** Global accessor for Android Application. Must be initialized at app startup. */
object ContextServices {
lateinit var app: Application
}
/** Checks if the device has a GPS receiver. */
fun Context.hasGps(): Boolean {
val lm = getSystemService(Context.LOCATION_SERVICE) as? LocationManager

View File

@@ -19,9 +19,9 @@ package org.meshtastic.core.common.util
import android.os.Build
/** Utility for checking build properties, such as emulator detection. */
object BuildUtils {
actual object BuildUtils {
/** Whether the app is currently running on an emulator. */
val isEmulator: Boolean
actual val isEmulator: Boolean
get() =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
@@ -32,4 +32,7 @@ object BuildUtils {
Build.MODEL.contains("Android SDK built for") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
actual val sdkInt: Int
get() = Build.VERSION.SDK_INT
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.net.Uri
actual class CommonUri(private val uri: Uri) {
actual val host: String?
get() = uri.host
actual val fragment: String?
get() = uri.fragment
actual val pathSegments: List<String>
get() = uri.pathSegments
actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
uri.getBooleanQueryParameter(key, defaultValue)
actual override fun toString(): String = uri.toString()
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
}
fun toUri(): Uri = uri
}
actual fun CommonUri.toPlatformUri(): Any = this.toUri()

View File

@@ -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.common.util
import android.text.format.DateUtils
import org.meshtastic.core.common.ContextServices
import java.text.DateFormat
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = DateUtils.getRelativeTimeSpanString(
timestampMillis,
nowMillis,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
)
.toString()
actual fun formatDateTime(timestampMillis: Long): String = DateUtils.formatDateTime(
ContextServices.app,
timestampMillis,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
actual fun formatShortDate(timestampMillis: Long): String {
val now = nowMillis
val isWithin24Hours = (now - timestampMillis) <= DateUtils.DAY_IN_MILLIS
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(timestampMillis)
} else {
DateFormat.getDateInstance(DateFormat.SHORT).format(timestampMillis)
}
}
}

View File

@@ -19,18 +19,6 @@ package org.meshtastic.core.common.util
import android.os.RemoteException
import co.touchlab.kermit.Logger
/**
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
* should not crash the process but are still unexpected.
*/
fun exceptionReporter(inner: () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
/**
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
* interface.

View File

@@ -0,0 +1,61 @@
/*
* 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.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import java.util.Locale
@Suppress("MagicNumber")
actual fun getSystemMeasurementSystem(): MeasurementSystem {
val locale = Locale.getDefault()
// Android 14+ (API 34) introduced user-settable locale preferences.
if (Build.VERSION.SDK_INT >= 34) {
try {
val localePrefsClass = Class.forName("androidx.core.text.util.LocalePreferences")
val getMeasurementSystemMethod =
localePrefsClass.getMethod("getMeasurementSystem", Locale::class.java, Boolean::class.javaPrimitiveType)
val result = getMeasurementSystemMethod.invoke(null, locale, true) as String
return when (result) {
"us",
"uk",
-> MeasurementSystem.IMPERIAL
else -> MeasurementSystem.METRIC
}
} catch (@Suppress("TooGenericExceptionCaught") ignored: Exception) {
// Fallback
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) {
LocaleData.MeasurementSystem.SI -> MeasurementSystem.METRIC
else -> MeasurementSystem.IMPERIAL
}
} else {
when (locale.country.uppercase(locale)) {
"US",
"LR",
"MM",
"GB",
-> MeasurementSystem.IMPERIAL
else -> MeasurementSystem.METRIC
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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.InetAddresses
import android.os.Build
import android.util.Patterns
actual fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
false
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
Patterns.IP_ADDRESS.matcher(this).matches() || Patterns.DOMAIN_NAME.matcher(this).matches()
} else {
InetAddresses.isNumericAddress(this) || Patterns.DOMAIN_NAME.matcher(this).matches()
}

View File

@@ -0,0 +1,31 @@
/*
* 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.os.Parcelable
actual typealias CommonParcelable = Parcelable
actual typealias CommonParcelize = kotlinx.parcelize.Parcelize
actual typealias CommonIgnoredOnParcel = kotlinx.parcelize.IgnoredOnParcel
actual typealias CommonParceler<T> = kotlinx.parcelize.Parceler<T>
actual typealias CommonTypeParceler<T, P> = kotlinx.parcelize.TypeParceler<T, P>
actual typealias CommonParcel = android.os.Parcel

View File

@@ -0,0 +1,26 @@
/*
* 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
/** Utility for checking build properties, such as emulator detection. */
expect object BuildUtils {
/** Whether the app is currently running on an emulator. */
val isEmulator: Boolean
/** The SDK version of the current platform. On non-Android platforms, this returns 0. */
val sdkInt: Int
}

View File

@@ -0,0 +1,37 @@
/*
* 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
/** 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>
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

View File

@@ -0,0 +1,33 @@
/*
* 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
/** Platform-agnostic Date formatter utility. */
expect object DateFormatter {
/** Formats a timestamp into a relative "time ago" string. */
fun formatRelativeTime(timestampMillis: Long): String
/** Formats a timestamp into a localized date and time string. */
fun formatDateTime(timestampMillis: Long): String
/**
* Formats a timestamp into a short date or time string.
*
* Typically shows time if within the last 24 hours, otherwise the date.
*/
fun formatShortDate(timestampMillis: Long): String
}

View File

@@ -46,3 +46,15 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
}
/**
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
* should not crash the process but are still unexpected.
*/
fun exceptionReporter(inner: () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}

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,14 +14,11 @@
* 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
package org.meshtastic.core.common.util
import android.annotation.SuppressLint
import org.meshtastic.core.model.Position
import java.util.Locale
import kotlin.math.PI
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
@@ -29,20 +26,34 @@ import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
@SuppressLint("PropertyNaming")
@Suppress("MagicNumber")
object GPSFormat {
fun toDec(latitude: Double, longitude: Double): String =
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
fun toDec(latitude: Double, longitude: Double): String {
// Simple decimal formatting for KMP
fun Double.format(digits: Int): String {
val multiplier = 10.0.pow(digits)
val rounded = (this * multiplier).toLong() / multiplier
return rounded.toString()
}
return "${latitude.format(5)}, ${longitude.format(5)}"
}
}
private const val EARTH_RADIUS_METERS = 6371e3
@Suppress("MagicNumber")
private fun Double.toRadians(): Double = this * PI / 180.0
@Suppress("MagicNumber")
private fun Double.toDegrees(): Double = this * 180.0 / PI
/** @return distance in meters along the surface of the earth (ish) */
@Suppress("MagicNumber")
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 lat1 = latitudeA.toRadians()
val lon1 = longitudeA.toRadians()
val lat2 = latitudeB.toRadians()
val lon2 = longitudeB.toRadians()
val dLat = lat2 - lat1
val dLon = lon2 - lon1
@@ -53,10 +64,6 @@ fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, lon
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.
*
@@ -68,16 +75,16 @@ fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitud
*/
@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 lat1Rad = lat1.toRadians()
val lon1Rad = lon1.toRadians()
val lat2Rad = lat2.toRadians()
val lon2Rad = lon2.toRadians()
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))
val bearing = atan2(y, x).toDegrees()
return (bearing + 360) % 360
}

View File

@@ -0,0 +1,26 @@
/*
* 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
/** Represents the system's preferred measurement system. */
enum class MeasurementSystem {
METRIC,
IMPERIAL,
}
/** returns the system's preferred measurement system. */
expect fun getSystemMeasurementSystem(): MeasurementSystem

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.common.util
/** Validates if the given string is a valid network address (IP or domain). */
expect fun String?.isValidAddress(): Boolean

View File

@@ -0,0 +1,58 @@
/*
* 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
/** Platform-agnostic Parcelable interface. */
expect interface CommonParcelable
/** Platform-agnostic Parcelize annotation. */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
expect annotation class CommonParcelize()
/** Platform-agnostic IgnoredOnParcel annotation. */
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
expect annotation class CommonIgnoredOnParcel()
/** Platform-agnostic Parceler interface. */
expect interface CommonParceler<T> {
fun create(parcel: CommonParcel): T
fun T.write(parcel: CommonParcel, flags: Int)
}
/** Platform-agnostic TypeParceler annotation. */
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
expect annotation class CommonTypeParceler<T, P : CommonParceler<in T>>()
/** Platform-agnostic Parcel representation for manual parceling (e.g. AIDL support). */
expect class CommonParcel {
fun readString(): String?
fun readInt(): Int
fun readLong(): Long
fun readFloat(): Float
fun createByteArray(): ByteArray?
fun writeByteArray(b: ByteArray?)
}

View File

@@ -16,14 +16,14 @@
*/
package org.meshtastic.core.database.model
import android.graphics.Color
import okio.ByteString
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
@@ -76,7 +76,9 @@ data class Node(
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 isUnknownUser
@@ -130,7 +132,7 @@ data class Node(
// @return bearing to the other position in degrees
fun bearing(o: Node?): Int? = when {
validPosition == null || o?.validPosition == null -> null
else -> org.meshtastic.core.model.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
else -> bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)

View File

@@ -1,7 +1,10 @@
# `:core:model`
# `:core:model` (Meshtastic Domain Models)
## Overview
The `:core:model` module contains the domain models and Parcelable data classes used throughout the application and its API. These models are designed to be shared between the service and client applications via AIDL.
The `:core:model` module is a **Kotlin Multiplatform (KMP)** library containing the domain models and data classes used throughout the application and its API. These models are platform-agnostic and designed to be shared across Android, JVM, and future supported platforms.
## Multiplatform Support
Models in this module use the `CommonParcelable` and `CommonParcelize` abstractions from `:core:common`. This allows them to maintain Android `Parcelable` compatibility (via `@Parcelize`) while residing in `commonMain` and remaining accessible to non-Android targets.
## Key Models
@@ -14,9 +17,15 @@ The `:core:model` module contains the domain models and Parcelable data classes
This module is a core dependency of `core:api` and most feature modules.
```kotlin
// In commonMain
implementation(projects.core.model)
```
## Structure
- **`commonMain`**: Contains the majority of domain models and logic.
- **`androidMain`**: Contains Android-specific utilities and implementations for `expect` declarations.
- **`androidUnitTest`**: Contains unit tests that require Android-specific features (like `Parcel` testing via Robolectric).
## Module dependency graph
<!--region graph-->

View File

@@ -14,10 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.kotlin.parcelize)
`maven-publish`
@@ -25,48 +24,34 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
configure<LibraryExtension> {
namespace = "org.meshtastic.core.model"
buildFeatures {
buildConfig = true
aidl = true
kotlin {
sourceSets {
commonMain.dependencies {
api(projects.core.proto)
api(projects.core.common)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
implementation(libs.kermit)
api(libs.okio)
}
androidMain.dependencies {
api(libs.androidx.annotation)
implementation(libs.zxing.core)
}
commonTest.dependencies { implementation(kotlin("test")) }
}
defaultConfig {
// Lowering minSdk to 21 for better compatibility with ATAK and other plugins
minSdk = 21
}
testOptions { unitTests { isIncludeAndroidResources = true } }
publishing { singleVariant("release") { withSourcesJar() } }
}
afterEvaluate {
publishing {
publications {
create<MavenPublication>("release") {
from(components["release"])
artifactId = "meshtastic-android-model"
}
// Modern KMP publication uses the project name as the artifactId by default.
// We rename the publications to include the 'core-' prefix for consistency.
publishing {
publications.withType<MavenPublication>().configureEach {
val baseId = artifactId
if (baseId == "model") {
artifactId = "meshtastic-android-model"
} else if (baseId.startsWith("model-")) {
artifactId = baseId.replace("model-", "meshtastic-android-model-")
}
}
}
dependencies {
api(projects.core.proto)
api(projects.core.common)
api(libs.androidx.annotation)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
implementation(libs.kermit)
implementation(libs.zxing.core)
testImplementation(libs.androidx.core.ktx)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
}

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

@@ -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,18 +14,21 @@
* 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
package com.geeksville.mesh.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: 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

@@ -20,9 +20,9 @@
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
>
<path
android:fillColor="@android:color/white"
android:fillColor="#ffffffff"
android:pathData="M12 7.5C12.69 7.5 13.27 7.73 13.76 8.2S14.5 9.27 14.5 10C14.5 11.05 14 11.81 13 12.28V21H11V12.28C10 11.81 9.5 11.05 9.5 10C9.5 9.27 9.76 8.67 10.24 8.2S11.31 7.5 12 7.5M16.69 5.3C17.94 6.55 18.61 8.11 18.7 10C18.7 11.8 18.03 13.38 16.69 14.72L15.5 13.5C16.5 12.59 17 11.42 17 10C17 8.67 16.5 7.5 15.5 6.5L16.69 5.3M6.09 4.08C4.5 5.67 3.7 7.64 3.7 10S4.5 14.3 6.09 15.89L4.92 17.11C3 15.08 2 12.7 2 10C2 7.3 3 4.94 4.92 2.91L6.09 4.08M19.08 2.91C21 4.94 22 7.3 22 10C22 12.8 21 15.17 19.08 17.11L17.91 15.89C19.5 14.3 20.3 12.33 20.3 10S19.5 5.67 17.91 4.08L19.08 2.91M7.31 5.3L8.5 6.5C7.5 7.42 7 8.58 7 10C7 11.33 7.5 12.5 8.5 13.5L7.31 14.72C5.97 13.38 5.3 11.8 5.3 10C5.3 8.2 5.97 6.64 7.31 5.3Z"
android:fillAlpha=".5"
/>

View File

@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:fillColor="#ffffffff"
android:pathData="M14,20H6V6H14M14.67,4H13V2H7V4H5.33C4.6,4 4,4.6 4,5.33V20.67C4,21.4 4.6,22 5.33,22H14.67C15.4,22 16,21.4 16,20.67V5.33C16,4.6 15.4,4 14.67,4M21,7H19V13H21V8M21,15H19V17H21V15Z"
android:fillAlpha="0.5"
/>

View File

@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:fillColor="#ffffffff"
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16M15,7H9V10H15V7M15,11.5H9V14.5H15V11.5Z"
android:fillAlpha="0.5"
/>

View File

@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal"
>
<path
android:fillColor="@android:color/white"
android:fillColor="#ffffffff"
android:pathData="M16,20H8V6H16M16.67,4H15V2H9V4H7.33C6.6,4 6,4.6 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67C17.41,22 18,21.41 18,20.67V5.33C18,4.6 17.4,4 16.67,4M15,16H9V19H15V16"
android:fillAlpha="0.5"
/>

Some files were not shown because too many files have changed in this diff Show More