mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
b3f88bd94f
commit
d408964f07
144 changed files with 1460 additions and 664 deletions
|
|
@ -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-->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 =
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.meshtastic.core.common.util.CommonParcelable
|
||||
import org.meshtastic.core.common.util.CommonParcelize
|
||||
|
||||
@CommonParcelize
|
||||
data class Contact(
|
||||
val contactKey: String,
|
||||
val shortName: String,
|
||||
val longName: String,
|
||||
val lastMessageTime: Long?,
|
||||
val lastMessageText: String?,
|
||||
val unreadCount: Int,
|
||||
val messageCount: Int,
|
||||
val isMuted: Boolean,
|
||||
val isUnmessageable: Boolean,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
) : CommonParcelable
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable DataPacket;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable MeshUser;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable MyNodeInfo;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable NodeInfo;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
package org.meshtastic.core.model;
|
||||
|
||||
parcelable Position;
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import org.meshtastic.core.model.Position
|
||||
import java.util.Locale
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@SuppressLint("PropertyNaming")
|
||||
object GPSFormat {
|
||||
fun toDec(latitude: Double, longitude: Double): String =
|
||||
String.format(Locale.getDefault(), "%.5f, %.5f", latitude, longitude)
|
||||
}
|
||||
|
||||
private const val EARTH_RADIUS_METERS = 6371e3
|
||||
|
||||
/** @return distance in meters along the surface of the earth (ish) */
|
||||
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
|
||||
val lat1 = Math.toRadians(latitudeA)
|
||||
val lon1 = Math.toRadians(longitudeA)
|
||||
val lat2 = Math.toRadians(latitudeB)
|
||||
val lon2 = Math.toRadians(longitudeB)
|
||||
|
||||
val dLat = lat2 - lat1
|
||||
val dLon = lon2 - lon1
|
||||
|
||||
val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
|
||||
val c = 2 * asin(sqrt(a))
|
||||
|
||||
return EARTH_RADIUS_METERS * c
|
||||
}
|
||||
|
||||
// Same as above, but takes Mesh Position proto.
|
||||
@Suppress("MagicNumber")
|
||||
fun positionToMeter(a: Position, b: Position): Double = latLongToMeter(a.latitude, a.longitude, b.latitude, b.longitude)
|
||||
|
||||
/**
|
||||
* Computes the bearing in degrees between two points on Earth.
|
||||
*
|
||||
* @param lat1 Latitude of the first point
|
||||
* @param lon1 Longitude of the first point
|
||||
* @param lat2 Latitude of the second point
|
||||
* @param lon2 Longitude of the second point
|
||||
* @return Bearing between the two points in degrees. A value of 0 means due north.
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val lat1Rad = Math.toRadians(lat1)
|
||||
val lon1Rad = Math.toRadians(lon1)
|
||||
val lat2Rad = Math.toRadians(lat2)
|
||||
val lon2Rad = Math.toRadians(lon2)
|
||||
|
||||
val dLon = lon2Rad - lon1Rad
|
||||
|
||||
val y = sin(dLon) * cos(lat2Rad)
|
||||
val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
|
||||
val bearing = Math.toDegrees(atan2(y, x))
|
||||
|
||||
return (bearing + 360) % 360
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue