From d408964f07bbd1444e5f1dd4e8b99f8119106ac6 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Feb 2026 06:37:33 -0600
Subject: [PATCH] refactor: KMP Migration, Messaging Modularization, and
Handshake Robustness (#4631)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
AGENTS.md | 9 ++
.../geeksville/mesh/MeshUtilApplication.kt | 2 +
.../mesh/navigation/ContactsNavigation.kt | 34 ++++-
.../mesh/service/MeshConfigFlowManager.kt | 86 ++++++-----
.../mesh/service/MeshConnectionManager.kt | 55 +++++--
.../geeksville/mesh/service/MeshDataMapper.kt | 33 +---
.../mesh/service/MeshNodeManager.kt | 7 +-
.../service/MeshServiceNotificationsImpl.kt | 144 +++++++++++++-----
.../main/java/com/geeksville/mesh/ui/Main.kt | 71 +++++----
.../mesh/ui/connections/ConnectionsScreen.kt | 17 +--
.../components/CurrentlyConnectedInfo.kt | 19 ++-
.../connections/components/NetworkDevices.kt | 2 +-
.../com/geeksville/mesh/ui/sharing/Channel.kt | 6 +-
.../main/res/drawable-anydpi/ic_splash.xml | 4 +-
.../mesh/service/MeshDataMapperTest.kt | 18 +--
core/api/README.md | 4 +-
.../org/meshtastic/core/model/DataPacket.aidl | 0
.../org/meshtastic/core/model/MeshUser.aidl | 0
.../org/meshtastic/core/model/MyNodeInfo.aidl | 0
.../org/meshtastic/core/model/NodeInfo.aidl | 0
.../org/meshtastic/core/model/Position.aidl | 0
core/common/build.gradle.kts | 5 +-
.../meshtastic/core/common/ContextServices.kt | 6 +
.../meshtastic/core/common/util/BuildUtils.kt | 7 +-
.../core/common/util/CommonUri.android.kt | 45 ++++++
.../core/common/util/DateFormatter.android.kt | 48 ++++++
.../core/common/util/ExceptionsAndroid.kt | 12 --
.../core/common/util/LocaleUtils.android.kt | 61 ++++++++
.../core/common/util/NetworkUtils.android.kt | 30 ++++
.../core/common/util/Parcelable.android.kt | 31 ++++
.../meshtastic/core/common/util/BuildUtils.kt | 26 ++++
.../meshtastic/core/common/util/CommonUri.kt | 37 +++++
.../core/common/util/DateFormatter.kt | 33 ++++
.../meshtastic/core/common/util/Exceptions.kt | 12 ++
.../core/common}/util/LocationUtils.kt | 51 ++++---
.../core/common/util/MeasurementSystem.kt | 26 ++++
.../core/common/util/NetworkUtils.kt | 20 +++
.../meshtastic/core/common/util/Parcelable.kt | 58 +++++++
.../meshtastic/core/database/model/Node.kt | 12 +-
core/model/README.md | 13 +-
core/model/build.gradle.kts | 67 ++++----
.../core/model/util/DateTimeUtils.kt | 8 -
.../meshtastic/core/model/util/DebugUtils.kt | 19 +++
.../core/model/util/PosixTimeZoneUtils.kt | 0
.../meshtastic/core/model/util/QrCodeUtils.kt | 52 +++++++
.../meshtastic/core/model/util/RandomUtils.kt | 25 +++
.../meshtastic/core/model/util/SfppHasher.kt | 0
.../meshtastic/core/model/util/UriBridge.kt | 38 +++++
.../meshtastic/core/model/CapabilitiesTest.kt | 0
.../core/model/ChannelOptionTest.kt | 0
.../core/model/DataPacketParcelTest.kt | 0
.../meshtastic/core/model/DataPacketTest.kt | 0
.../core/model/DeviceVersionTest.kt | 3 +-
.../org/meshtastic/core/model/NodeInfoTest.kt | 0
.../org/meshtastic/core/model/PositionTest.kt | 3 +-
.../core/model/util/ExtensionsTest.kt | 0
.../core/model/util/SfppHasherTest.kt | 0
.../core/model/util/SharedContactTest.kt | 0
.../core/model/util/TimeExtensionsTest.kt | 0
.../core/model/util/UnitConversionsTest.kt | 0
.../core/model/util/UriUtilsTest.kt | 0
.../core/model/util/WireExtensionsTest.kt | 0
.../core/model/BootloaderOtaQuirk.kt | 0
.../org/meshtastic/core/model/Capabilities.kt | 4 +-
.../org/meshtastic/core/model/Channel.kt | 11 +-
.../meshtastic/core/model/ChannelOption.kt | 0
.../org/meshtastic/core}/model/Contact.kt | 11 +-
.../org/meshtastic/core/model/DataPacket.kt | 32 ++--
.../meshtastic/core/model/DeviceHardware.kt | 0
.../meshtastic/core/model/DeviceVersion.kt | 3 +-
.../org/meshtastic/core/model/MyNodeInfo.kt | 8 +-
.../org/meshtastic/core/model/NeighborInfo.kt | 0
.../core/model/NetworkDeviceHardware.kt | 3 +-
.../core/model/NetworkFirmwareRelease.kt | 3 +-
.../org/meshtastic/core/model/NodeInfo.kt | 44 +++---
.../meshtastic/core/model/RouteDiscovery.kt | 0
.../meshtastic/core/model/TelemetryType.kt | 0
.../core/model/util/ByteStringExtensions.kt | 8 +-
.../core/model/util/ByteStringSerializer.kt | 10 +-
.../meshtastic/core/model/util/ChannelSet.kt | 61 ++------
.../meshtastic/core/model/util/CommonUtils.kt | 0
.../meshtastic/core/model/util/DebugUtils.kt | 19 +++
.../core/model/util/DistanceExtensions.kt | 26 +---
.../meshtastic/core/model/util/Extensions.kt | 6 +-
.../core/model/util/LocationUtils.kt | 24 +++
.../util/MalformedMeshtasticUrlException.kt | 20 +++
.../core/model/util/MeshDataMapper.kt | 55 +++++++
.../core/model/util/MeshtasticUrlConstants.kt | 0
.../core/model/util/NodeIdLookup.kt | 23 +++
.../meshtastic/core/model/util/RandomUtils.kt | 19 +++
.../core/model/util/SharedContact.kt | 54 ++++---
.../core/model/util/TimeConstants.kt | 0
.../meshtastic/core/model/util/TimeUtils.kt | 24 +++
.../core/model/util/UnitConversions.kt | 0
.../meshtastic/core/model/util/UriUtils.kt | 10 +-
.../core/model/util/WireExtensions.kt | 0
.../composeResources/drawable/ic_antenna.xml | 4 +-
.../drawable/ic_battery_alert.xml | 4 +-
.../drawable/ic_battery_high.xml | 4 +-
.../drawable/ic_battery_low.xml | 4 +-
.../drawable/ic_battery_medium.xml | 4 +-
.../drawable/ic_battery_outline.xml | 4 +-
.../drawable/ic_battery_unknown.xml | 4 +-
.../drawable/ic_counter_0.xml | 4 +-
.../drawable/ic_counter_1.xml | 4 +-
.../drawable/ic_counter_2.xml | 4 +-
.../drawable/ic_counter_3.xml | 4 +-
.../drawable/ic_counter_4.xml | 4 +-
.../drawable/ic_counter_5.xml | 4 +-
.../drawable/ic_counter_6.xml | 4 +-
.../drawable/ic_counter_7.xml | 4 +-
.../drawable/ic_counter_8.xml | 4 +-
.../drawable/ic_location_on.xml | 4 +-
.../drawable/ic_lock_open_right.xml | 2 +-
.../drawable/ic_map_location_dot.xml | 2 +-
.../drawable/ic_map_navigation.xml | 2 +-
.../drawable/ic_meshtastic.xml | 2 +-
.../drawable/ic_mountain_flag.xml | 4 +-
.../drawable/ic_power_plug.xml | 4 +-
.../drawable/ic_radioactive.xml | 6 +-
.../composeResources/values/strings.xml | 12 ++
.../core/ui/component/ContactSharing.kt | 8 +-
.../core/ui/component/NodeKeyStatusIcon.kt | 3 +-
.../org/meshtastic/core/ui/util/FormatAgo.kt | 15 +-
.../core/ui/util/ProtoExtensions.kt | 9 +-
.../feature/firmware/FirmwareDfuService.kt | 4 +-
.../feature/firmware/FirmwareUpdateScreen.kt | 5 +-
.../org/meshtastic/feature/map/MapView.kt | 25 ++-
.../feature/map/model/NOAAWmsTileSource.kt | 9 +-
.../fdroid/res/drawable/ic_location_on.xml | 6 +-
.../res/drawable/ic_map_location_dot.xml | 4 +-
.../fdroid/res/drawable/ic_map_navigation.xml | 4 +-
feature/messaging/build.gradle.kts | 29 ++--
.../ui/contact/AdaptiveContactsScreen.kt | 17 ++-
.../messaging}/ui/contact/ContactItem.kt | 22 ++-
.../feature/messaging}/ui/contact/Contacts.kt | 29 ++--
.../ui/contact/ContactsViewModel.kt | 45 +++---
.../feature/messaging}/ui/sharing/Share.kt | 10 +-
.../feature/node/compass/CompassViewModel.kt | 4 +-
.../node/component/LinkedCoordinatesItem.kt | 2 +-
.../feature/node/metrics/NeighborInfoLog.kt | 12 +-
.../feature/node/metrics/TracerouteLog.kt | 18 +--
.../radio/component/NetworkConfigItemList.kt | 3 +-
gradle/libs.versions.toml | 2 +
144 files changed, 1460 insertions(+), 664 deletions(-)
rename core/{model => api}/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl (100%)
rename core/{model => api}/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl (100%)
rename core/{model => api}/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl (100%)
rename core/{model => api}/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl (100%)
rename core/{model => api}/src/main/aidl/org/meshtastic/core/model/Position.aidl (100%)
create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/DateFormatter.android.kt
create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt
create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.android.kt
create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DateFormatter.kt
rename core/{model/src/main/kotlin/org/meshtastic/core/model => common/src/commonMain/kotlin/org/meshtastic/core/common}/util/LocationUtils.kt (65%)
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt
rename core/model/src/{main => androidMain}/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt (93%)
create mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt
rename core/model/src/{main => androidMain}/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt (100%)
create mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt
create mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt
rename core/model/src/{main => androidMain}/kotlin/org/meshtastic/core/model/util/SfppHasher.kt (100%)
create mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/DataPacketTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt (96%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/NodeInfoTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/PositionTest.kt (96%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt (100%)
rename core/model/src/{test => androidUnitTest}/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/Capabilities.kt (97%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/Channel.kt (93%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/ChannelOption.kt (100%)
rename {app/src/main/java/com/geeksville/mesh => core/model/src/commonMain/kotlin/org/meshtastic/core}/model/Contact.kt (78%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/DataPacket.kt (89%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/DeviceHardware.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/DeviceVersion.kt (97%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/MyNodeInfo.kt (90%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/NeighborInfo.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt (97%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt (97%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/NodeInfo.kt (90%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/RouteDiscovery.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/TelemetryType.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt (76%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt (83%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/ChannelSet.kt (61%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/CommonUtils.kt (100%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt (78%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/Extensions.kt (96%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/LocationUtils.kt
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt (100%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/NodeIdLookup.kt
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/SharedContact.kt (74%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/TimeConstants.kt (100%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/UnitConversions.kt (100%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/UriUtils.kt (93%)
rename core/model/src/{main => commonMain}/kotlin/org/meshtastic/core/model/util/WireExtensions.kt (100%)
rename {app/src/main/java/com/geeksville/mesh => feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging}/ui/contact/AdaptiveContactsScreen.kt (90%)
rename {app/src/main/java/com/geeksville/mesh => feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging}/ui/contact/ContactItem.kt (94%)
rename {app/src/main/java/com/geeksville/mesh => feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging}/ui/contact/Contacts.kt (96%)
rename {app/src/main/java/com/geeksville/mesh => feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging}/ui/contact/ContactsViewModel.kt (84%)
rename {app/src/main/java/com/geeksville/mesh => feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging}/ui/sharing/Share.kt (94%)
diff --git a/AGENTS.md b/AGENTS.md
index 69027f403..882c6c1f7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
index 8ddb77899..24c761128 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
+++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
index 60f3e60fd..aaf47dde6 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt
@@ -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 .
*/
-
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) {
@@ -37,7 +40,19 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
composable(
deepLinks = listOf(navDeepLink(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(
deepLinks =
@@ -49,9 +64,18 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
),
) { backStackEntry ->
val args = backStackEntry.toRoute()
+ 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,
)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
index 2b0f8faef..ad3f64d34 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
@@ -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" }
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
index f74668425..ec3f2bfa3 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -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"
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt
index bf9450b30..2e4c605ea 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt
@@ -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)
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
index 314b823d4..ce6d4431c 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
@@ -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()
@@ -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)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
index 0a37174ee..67447d628 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -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()
+ 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()
+ 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()
+ 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()
- 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()
- 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")
-}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index bc3b82a6d..c4f9d3fb5 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -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 {
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
index 7f9c74d59..a7b34c125 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
@@ -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
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt
index e4b711580..eb359ca00 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt
@@ -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,
+ )
+ }
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
index ea7edb5ff..cc0f8af7a 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
index b3960ecd7..693dbf61f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
@@ -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,
)
diff --git a/app/src/main/res/drawable-anydpi/ic_splash.xml b/app/src/main/res/drawable-anydpi/ic_splash.xml
index 58bcfa526..4357c9a48 100644
--- a/app/src/main/res/drawable-anydpi/ic_splash.xml
+++ b/app/src/main/res/drawable-anydpi/ic_splash.xml
@@ -10,12 +10,12 @@
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt
index 0c3d456ef..5b01cbed3 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshDataMapperTest.kt
@@ -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()
- 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()
- 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)
diff --git a/core/api/README.md b/core/api/README.md
index 068967f97..37ddf1a10 100644
--- a/core/api/README.md
+++ b/core/api/README.md
@@ -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")
}
```
diff --git a/core/model/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl
similarity index 100%
rename from core/model/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl
rename to core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl
diff --git a/core/model/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl
similarity index 100%
rename from core/model/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl
rename to core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl
diff --git a/core/model/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl
similarity index 100%
rename from core/model/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl
rename to core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl
diff --git a/core/model/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl
similarity index 100%
rename from core/model/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl
rename to core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl
diff --git a/core/model/src/main/aidl/org/meshtastic/core/model/Position.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl
similarity index 100%
rename from core/model/src/main/aidl/org/meshtastic/core/model/Position.aidl
rename to core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 6a0add7f4..8f55e26fc 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -15,7 +15,10 @@
* along with this program. If not, see .
*/
-plugins { alias(libs.plugins.meshtastic.kmp.library) }
+plugins {
+ alias(libs.plugins.meshtastic.kmp.library)
+ alias(libs.plugins.kotlin.parcelize)
+}
kotlin {
@Suppress("UnstableApiUsage")
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt
index 4a2efd901..ad4629fba 100644
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/ContextServices.kt
@@ -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
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt
index 99e454fb0..9db5b16da 100644
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt
@@ -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
}
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
new file mode 100644
index 000000000..a99bccd84
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
@@ -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 .
+ */
+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
+ 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()
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/DateFormatter.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/DateFormatter.android.kt
new file mode 100644
index 000000000..f9cd95e8e
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/DateFormatter.android.kt
@@ -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 .
+ */
+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)
+ }
+ }
+}
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt
index 37679c4cf..767b983c1 100644
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt
@@ -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.
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt
new file mode 100644
index 000000000..f0ff08022
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt
@@ -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 .
+ */
+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
+ }
+ }
+}
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.android.kt
new file mode 100644
index 000000000..f7b2f663a
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.android.kt
@@ -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 .
+ */
+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()
+}
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt
new file mode 100644
index 000000000..0b89e9894
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt
@@ -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 .
+ */
+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 = kotlinx.parcelize.Parceler
+
+actual typealias CommonTypeParceler = kotlinx.parcelize.TypeParceler
+
+actual typealias CommonParcel = android.os.Parcel
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt
new file mode 100644
index 000000000..c216af677
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/BuildUtils.kt
@@ -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 .
+ */
+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
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
new file mode 100644
index 000000000..7079cbf5e
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
@@ -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 .
+ */
+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
+
+ 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
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DateFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DateFormatter.kt
new file mode 100644
index 000000000..2a6ddd2db
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DateFormatter.kt
@@ -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 .
+ */
+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
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
index 0a612fb40..c0a728312 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
@@ -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")
+ }
+}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/LocationUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
similarity index 65%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/LocationUtils.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
index c2a194a9c..6ca954806 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/LocationUtils.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
@@ -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 .
*/
-
@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
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt
new file mode 100644
index 000000000..968339f78
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt
@@ -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 .
+ */
+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
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.kt
new file mode 100644
index 000000000..773cdbc09
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/NetworkUtils.kt
@@ -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 .
+ */
+package org.meshtastic.core.common.util
+
+/** Validates if the given string is a valid network address (IP or domain). */
+expect fun String?.isValidAddress(): Boolean
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt
new file mode 100644
index 000000000..b759bfdbb
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt
@@ -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 .
+ */
+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 {
+ 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>()
+
+/** 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?)
+}
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
index 8b3df569a..64cc0c101 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
@@ -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)
diff --git a/core/model/README.md b/core/model/README.md
index a218d06c4..2c5f91338 100644
--- a/core/model/README.md
+++ b/core/model/README.md
@@ -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
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 003752657..902098124 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -14,10 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-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 {
- 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("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().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)
-}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
similarity index 93%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
rename to core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
index c56728b9c..9be12ee55 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
@@ -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.
*
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt
new file mode 100644
index 000000000..eedaba0d8
--- /dev/null
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt
@@ -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 .
+ */
+package org.meshtastic.core.model.util
+
+actual val isDebug: Boolean = false
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt
rename to core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt
new file mode 100644
index 000000000..9c38c4d4f
--- /dev/null
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt
@@ -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 .
+ */
+@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
+}
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt
new file mode 100644
index 000000000..35e63eff7
--- /dev/null
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt
@@ -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 .
+ */
+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
+}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
rename to core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
new file mode 100644
index 000000000..13b0789de
--- /dev/null
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
@@ -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 .
+ */
+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()
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
similarity index 96%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
index a353230a6..59148464c 100644
--- a/core/model/src/test/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
+++ b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
@@ -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 .
*/
-
package org.meshtastic.core.model
import org.junit.Assert.assertEquals
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/NodeInfoTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/PositionTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
similarity index 96%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/PositionTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
index c120e7753..f07ad83dd 100644
--- a/core/model/src/test/kotlin/org/meshtastic/core/model/PositionTest.kt
+++ b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
@@ -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 .
*/
-
package org.meshtastic.core.model
import org.junit.Assert
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
similarity index 100%
rename from core/model/src/test/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
rename to core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
similarity index 97%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index 3d481cd3c..e5c069fc9 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -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 =
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
similarity index 93%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
index a8fe72c55..67c2d4256 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
@@ -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)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
diff --git a/app/src/main/java/com/geeksville/mesh/model/Contact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt
similarity index 78%
rename from app/src/main/java/com/geeksville/mesh/model/Contact.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt
index ea2723e99..7df9f63af 100644
--- a/app/src/main/java/com/geeksville/mesh/model/Contact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt
@@ -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 .
*/
+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? = null,
-)
+) : CommonParcelable
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
similarity index 89%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
index a27006f0e..e7f0b44e4 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
@@ -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
+ @CommonTypeParceler
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
+ @CommonTypeParceler
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
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
similarity index 97%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/DeviceVersion.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
index d9eda30cb..64d210f5d 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceVersion.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
@@ -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 .
*/
-
package org.meshtastic.core.model
import co.touchlab.kermit.Logger
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
similarity index 90%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
index aaab77ebc..b8c543840 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt
@@ -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"
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt
similarity index 97%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt
index 5e9c9cd52..5034ccb17 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkDeviceHardware.kt
@@ -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 .
*/
-
package org.meshtastic.core.model
import kotlinx.serialization.ExperimentalSerializationApi
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt
similarity index 97%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt
index 33f027673..258e842a5 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NetworkFirmwareRelease.kt
@@ -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 .
*/
-
package org.meshtastic.core.model
import kotlinx.serialization.SerialName
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt
similarity index 90%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt
index 9afe53f4c..daa93a144 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt
@@ -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
@@ -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
}
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TelemetryType.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/TelemetryType.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/TelemetryType.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt
similarity index 76%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt
index 206529504..7a609a258 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringExtensions.kt
@@ -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")
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt
similarity index 83%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt
index c3012d88d..3f8c9b41c 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt
@@ -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 {
@@ -40,10 +40,10 @@ object ByteStringSerializer : KSerializer {
}
/** Parceler for Okio [ByteString] for Android Parcelable support */
-object ByteStringParceler : Parceler {
- override fun create(parcel: Parcel): ByteString? = parcel.createByteArray()?.toByteString()
+object ByteStringParceler : CommonParceler {
+ 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())
}
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
similarity index 61%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
index ac9e6a7f5..ff4d3c792 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
@@ -14,31 +14,24 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@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")
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/CommonUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/CommonUtils.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/CommonUtils.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/CommonUtils.kt
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt
new file mode 100644
index 000000000..f0df078bb
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DebugUtils.kt
@@ -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 .
+ */
+package org.meshtastic.core.model.util
+
+expect val isDebug: Boolean
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
similarity index 78%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
index c0e54c3af..ea7e37340 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
@@ -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
+ }
}
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
similarity index 96%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 148de89a2..6f27bb0e6 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -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) {
""
} 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
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/LocationUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/LocationUtils.kt
new file mode 100644
index 000000000..70243c74b
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/LocationUtils.kt
@@ -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 .
+ */
+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)
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt
new file mode 100644
index 000000000..bca7cd581
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt
@@ -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 .
+ */
+package org.meshtastic.core.model.util
+
+/** Exception thrown when a Meshtastic URL cannot be parsed. */
+class MalformedMeshtasticUrlException(message: String) : Exception(message)
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
new file mode 100644
index 000000000..c39fa98a0
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt
@@ -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 .
+ */
+@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,
+ )
+ }
+}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshtasticUrlConstants.kt
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/NodeIdLookup.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/NodeIdLookup.kt
new file mode 100644
index 000000000..4235d2e66
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/NodeIdLookup.kt
@@ -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 .
+ */
+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
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt
new file mode 100644
index 000000000..f73f39eb4
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/RandomUtils.kt
@@ -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 .
+ */
+package org.meshtastic.core.model.util
+
+expect fun platformRandomBytes(size: Int): ByteArray
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
similarity index 74%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
index 70aea71c9..4ab635a6d 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -14,32 +14,31 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
+@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()
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeConstants.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeConstants.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeConstants.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeConstants.kt
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt
new file mode 100644
index 000000000..cb073317a
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt
@@ -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 .
+ */
+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()
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/UnitConversions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/UnitConversions.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/UnitConversions.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/UnitConversions.kt
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/UriUtils.kt
similarity index 93%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/UriUtils.kt
index 9aeee82fe..415bcf412 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/UriUtils.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/UriUtils.kt
@@ -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,
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/WireExtensions.kt
similarity index 100%
rename from core/model/src/main/kotlin/org/meshtastic/core/model/util/WireExtensions.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/WireExtensions.kt
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml b/core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml
index 9e0358300..bdbe21f5c 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_antenna.xml
@@ -20,9 +20,9 @@
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal">
+ >
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml
index aab98bc9d..46acf0dfd 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_alert.xml
@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal"
+
>
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml
index 032956f30..84515a2ae 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_high.xml
@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal"
+
>
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml
index 2126c0bc3..03494c93a 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_low.xml
@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal"
+
>
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml
index e60a81575..9f2ec050c 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_medium.xml
@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal"
+
>
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml
index ec515ed01..04ddd0c30 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_outline.xml
@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal"
+
>
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml b/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml
index 6be9c7145..32a9765d6 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_battery_unknown.xml
@@ -4,10 +4,10 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal"
+
>
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml
index 06e907d37..2b2f8bd7a 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_0.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml
index 34289e1da..b7997e6a4 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_1.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml
index 8029b8e29..e0f060afc 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_2.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml
index 47ccd7c63..a93cc6935 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_3.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml
index 30e820929..3c86ac847 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_4.xml
@@ -15,8 +15,8 @@
~ along with this program. If not, see .
-->
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml
index 8a5b92179..881e384c4 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_5.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml
index 1a41b158d..10854b64a 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_6.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml
index 874589a96..9bfc82753 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_7.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml b/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml
index 6e88d7fa5..b90075109 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_counter_8.xml
@@ -1,5 +1,5 @@
-
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml b/core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml
index 3bf5b7133..620fd9336 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_location_on.xml
@@ -6,9 +6,9 @@
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml b/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml
index 243cd83d6..f0c7f63fd 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml
@@ -4,6 +4,6 @@
android:viewportWidth="960"
android:viewportHeight="960">
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml b/core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml
index 2fb6587cc..fb313c150 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml
@@ -7,5 +7,5 @@
android:fillColor="#3388ff"
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="2.0"
- android:strokeColor="@android:color/white" />
+ android:strokeColor="#ffffffff" />
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml b/core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml
index 6557bb984..387e9db8b 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml
@@ -7,5 +7,5 @@
android:fillColor="#3388ff"
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"
android:strokeWidth="1.5"
- android:strokeColor="@android:color/white" />
+ android:strokeColor="#ffffffff" />
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_meshtastic.xml b/core/resources/src/commonMain/composeResources/drawable/ic_meshtastic.xml
index 2c6079c25..8ec4bdf2f 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_meshtastic.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_meshtastic.xml
@@ -21,7 +21,7 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="#FFFFFF"
+
android:alpha="0.8">
+
-
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml b/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml
index 413062438..b231758fb 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_power_plug.xml
@@ -20,9 +20,9 @@
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal">
+ >
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_radioactive.xml b/core/resources/src/commonMain/composeResources/drawable/ic_radioactive.xml
index 13fdb3021..bc489f4a8 100644
--- a/core/resources/src/commonMain/composeResources/drawable/ic_radioactive.xml
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_radioactive.xml
@@ -4,12 +4,12 @@
android:viewportWidth="24"
android:viewportHeight="24">
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index f21df9c13..a426350e4 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -650,6 +650,7 @@
WiFi enabled
SSID
PSK
+ Get Document
Ethernet Options
Ethernet enabled
NTP server
@@ -1193,4 +1194,15 @@
Permission denied
Map style selection
+
+ Battery: %1$d%%
+ Nodes: %1$d online / %2$d total
+ Uptime: %1$s
+ ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ Traffic: TX %1$d / RX %2$d (Dupes: %3$d)
+ Relays: %1$d (Canceled: %2$d)
+ Diagnostics: %1$s
+ Noise %1$d dBm
+ Bad %1$d
+ Dropped %1$d
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
index 98036bab9..4fba06a9d 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
@@ -19,7 +19,6 @@
package org.meshtastic.core.ui.component
import android.graphics.Bitmap
-import android.graphics.Color
import android.net.Uri
import androidx.compose.runtime.Composable
import co.touchlab.kermit.Logger
@@ -28,6 +27,7 @@ import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.getSharedContactUrl
import org.meshtastic.core.resources.Res
@@ -44,7 +44,8 @@ import org.meshtastic.proto.SharedContact
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
if (contact == null) return
val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
- val uri = contactToShare.getSharedContactUrl()
+ val commonUri = contactToShare.getSharedContactUrl()
+ val uri = commonUri.toPlatformUri() as Uri
QrDialog(title = stringResource(Res.string.share_contact), uri = uri, qrCode = uri.qrCode, onDismiss = onDismiss)
}
@@ -80,7 +81,8 @@ private fun BitMatrix.toBitmap(): Bitmap {
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
+ // 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)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt
index bd7fc8e0d..ad1110867 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.ui.component
-import android.util.Base64
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -198,7 +197,7 @@ private fun KeyStatusDialog(title: StringResource, text: StringResource, key: By
if (isMismatch) {
stringResource(Res.string.error)
} else {
- Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP)
+ key.base64()
}
Text(
text = stringResource(Res.string.config_security_public_key) + ":",
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt
index 068f60ae4..6e5dadb59 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt
@@ -16,7 +16,7 @@
*/
package org.meshtastic.core.ui.util
-import android.text.format.DateUtils
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
@@ -29,9 +29,8 @@ import kotlin.time.Duration.Companion.seconds
/**
* Formats a given Unix timestamp (in seconds) into a relative "time ago" string.
*
- * For durations less than a minute, it returns "now". For longer durations, it uses Android's
- * `DateUtils.getRelativeTimeSpanString` to generate a concise, localized, and abbreviated representation (e.g., "5m
- * ago", "2h ago").
+ * For durations less than a minute, it returns "now". For longer durations, it uses DateFormatter to generate a
+ * concise, localized representation (e.g., "5m ago", "2h ago").
*
* @param lastSeenUnixSeconds The Unix timestamp in seconds to be formatted.
* @return A [String] representing the relative time that has passed.
@@ -46,12 +45,6 @@ fun formatAgo(lastSeenUnixSeconds: Int): String {
return if (diff < 1.minutes) {
getString(Res.string.now)
} else {
- DateUtils.getRelativeTimeSpanString(
- lastSeenDuration.inWholeMilliseconds,
- currentDuration.inWholeMilliseconds,
- DateUtils.MINUTE_IN_MILLIS,
- DateUtils.FORMAT_ABBREV_RELATIVE,
- )
- .toString()
+ DateFormatter.formatRelativeTime(lastSeenDuration.inWholeMilliseconds)
}
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
index 9eca1ba87..9b47b253f 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
@@ -16,10 +16,9 @@
*/
package org.meshtastic.core.ui.util
-import android.text.format.DateUtils
import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.unknown_age
@@ -40,11 +39,7 @@ fun Position.formatPositionTime(): String {
if (isOlderThanSixMonths) {
stringResource(Res.string.unknown_age)
} else {
- DateUtils.formatDateTime(
- LocalContext.current,
- (time ?: 0) * SECONDS_TO_MILLIS,
- DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
- )
+ DateFormatter.formatDateTime((time ?: 0) * SECONDS_TO_MILLIS)
}
return timeText
}
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt
index da1dc18fe..d23274478 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt
@@ -23,10 +23,10 @@ import android.content.Context
import kotlinx.coroutines.runBlocking
import no.nordicsemi.android.dfu.DfuBaseService
import org.jetbrains.compose.resources.getString
-import org.meshtastic.core.model.BuildConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_channel_description
import org.meshtastic.core.resources.firmware_update_channel_name
+import org.meshtastic.core.model.util.isDebug as isDebugFlag
class FirmwareDfuService : DfuBaseService() {
override fun onCreate() {
@@ -57,5 +57,5 @@ class FirmwareDfuService : DfuBaseService() {
null
}
- override fun isDebug(): Boolean = BuildConfig.DEBUG
+ override fun isDebug(): Boolean = isDebugFlag
}
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
index 1a52c79d2..d00daacba 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
@@ -580,7 +580,8 @@ private fun DeviceInfoCard(
val currentVersionString =
stringResource(
Res.string.firmware_update_currently_installed,
- currentFirmwareVersion ?: stringResource(Res.string.firmware_update_unknown_release),
+ currentFirmwareVersion?.takeIf { it.isNotBlank() }
+ ?: stringResource(Res.string.firmware_update_unknown_release),
)
Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString)
Spacer(Modifier.height(4.dp))
@@ -825,7 +826,7 @@ private fun VerificationFailedState(onRetry: () -> Unit, onIgnore: () -> Unit) {
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(horizontalArrangement = spacedBy(16.dp)) {
OutlinedButton(onClick = onRetry) {
Icon(MeshtasticIcons.Refresh, contentDescription = null)
Spacer(Modifier.width(8.dp))
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt
index 7f63d2b63..0c3882821 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt
+++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt
@@ -18,7 +18,6 @@ package org.meshtastic.feature.map
import android.Manifest
import android.graphics.Paint
-import android.text.format.DateUtils
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -85,6 +84,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.hasGps
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
@@ -496,12 +496,7 @@ fun MapView(
val pt = waypoint.data.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else ""
- val time =
- DateUtils.formatDateTime(
- context,
- waypoint.received_time,
- DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
- )
+ val time = DateFormatter.formatDateTime(waypoint.received_time)
val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt())
val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
val now = nowMillis
@@ -510,14 +505,7 @@ fun MapView(
when {
(pt.expire ?: 0) == 0 || pt.expire == Int.MAX_VALUE -> "Never"
expireTimeMillis <= now -> "Expired"
- else ->
- DateUtils.getRelativeTimeSpanString(
- expireTimeMillis,
- now,
- DateUtils.MINUTE_IN_MILLIS,
- DateUtils.FORMAT_ABBREV_RELATIVE,
- )
- .toString()
+ else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
@@ -719,6 +707,7 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
+ @Suppress("MagicNumber")
Column(
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -805,6 +794,7 @@ fun MapView(
text = stringResource(Res.string.show_precision_circle),
modifier = Modifier.weight(1f),
)
+ @Suppress("MagicNumber")
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
@@ -1075,7 +1065,8 @@ private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private const val WAYPOINT_ZOOM = 15.0
-private fun Double.toRad(): Double = Math.toRadians(this)
+@Suppress("MagicNumber")
+private fun Double.toRad(): Double = this * Math.PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
@@ -1116,6 +1107,8 @@ private fun offsetPolyline(
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
+
+ @Suppress("MagicNumber")
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt
index 40778cea4..16391721e 100644
--- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt
+++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/NOAAWmsTileSource.kt
@@ -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,11 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.map.model
import android.content.res.Resources
-import android.util.Log
+import co.touchlab.kermit.Logger
import org.osmdroid.api.IMapView
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
@@ -78,7 +77,7 @@ open class NOAAWmsTileSource(
private var forceHttp = false
init {
- Log.i(IMapView.LOGTAG, "WMS support is BETA. Please report any issues")
+ Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" }
layer = layername
this.version = version
this.srs = srs
@@ -165,7 +164,7 @@ open class NOAAWmsTileSource(
sb.append(bbox[minY]).append(",")
sb.append(bbox[maxX]).append(",")
sb.append(bbox[maxY])
- Log.i(IMapView.LOGTAG, sb.toString())
+ Logger.withTag(IMapView.LOGTAG).i { sb.toString() }
return sb.toString()
}
diff --git a/feature/map/src/fdroid/res/drawable/ic_location_on.xml b/feature/map/src/fdroid/res/drawable/ic_location_on.xml
index 3bf5b7133..b93f33174 100644
--- a/feature/map/src/fdroid/res/drawable/ic_location_on.xml
+++ b/feature/map/src/fdroid/res/drawable/ic_location_on.xml
@@ -6,9 +6,9 @@
-
+
\ No newline at end of file
diff --git a/feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml b/feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml
index 2fb6587cc..2935f162e 100644
--- a/feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml
+++ b/feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml
@@ -7,5 +7,5 @@
android:fillColor="#3388ff"
android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:strokeWidth="2.0"
- android:strokeColor="@android:color/white" />
-
+ android:strokeColor="#ffffffff" />
+
\ No newline at end of file
diff --git a/feature/map/src/fdroid/res/drawable/ic_map_navigation.xml b/feature/map/src/fdroid/res/drawable/ic_map_navigation.xml
index 6557bb984..83d579f8a 100644
--- a/feature/map/src/fdroid/res/drawable/ic_map_navigation.xml
+++ b/feature/map/src/fdroid/res/drawable/ic_map_navigation.xml
@@ -7,5 +7,5 @@
android:fillColor="#3388ff"
android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"
android:strokeWidth="1.5"
- android:strokeColor="@android:color/white" />
-
+ android:strokeColor="#ffffffff" />
+
\ No newline at end of file
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 5f787f3b4..36cbbe824 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -16,23 +16,6 @@
*/
import com.android.build.api.dsl.LibraryExtension
-/*
- * 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 .
- */
-
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.compose)
@@ -42,19 +25,29 @@ plugins {
configure { namespace = "org.meshtastic.feature.messaging" }
dependencies {
+ implementation(projects.core.analytics)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
+ implementation(projects.core.navigation)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.resources)
implementation(projects.core.ui)
- implementation(libs.androidx.compose.material.iconsExtended)
+ implementation(libs.accompanist.permissions)
+ implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material3.adaptive)
+ implementation(libs.androidx.compose.material3.adaptive.layout)
+ implementation(libs.androidx.compose.material3.adaptive.navigation)
+ implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.kermit)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
similarity index 90%
rename from app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt
rename to feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
index ee1649613..3c29a0d64 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/AdaptiveContactsScreen.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
@@ -14,8 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.ui.contact
+package org.meshtastic.feature.messaging.ui.contact
+import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -54,13 +55,20 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.SharedContact
-@Suppress("LongMethod")
+@Suppress("LongMethod", "LongParameterList")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveContactsScreen(
navController: NavHostController,
scrollToTopEvents: Flow,
+ sharedContactRequested: SharedContact?,
+ requestChannelSet: ChannelSet?,
+ onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
+ onClearSharedContactRequested: () -> Unit,
+ onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
initialMessage: String = "",
) {
@@ -115,6 +123,11 @@ fun AdaptiveContactsScreen(
AnimatedPane {
ContactsScreen(
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
+ sharedContactRequested = sharedContactRequested,
+ requestChannelSet = requestChannelSet,
+ onHandleScannedUri = onHandleScannedUri,
+ onClearSharedContactRequested = onClearSharedContactRequested,
+ onClearRequestChannelUrl = onClearRequestChannelUrl,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt
similarity index 94%
rename from app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt
rename to feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt
index fdb970abe..bca0563be 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactItem.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.ui.contact
+package org.meshtastic.feature.messaging.ui.contact
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
@@ -51,8 +51,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
-import com.geeksville.mesh.model.Contact
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.model.Contact
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.some_username
@@ -117,14 +118,9 @@ private fun ContactHeader(
onNodeChipClick: () -> Unit = {},
) {
val colors =
- if (contact.nodeColors != null) {
- AssistChipDefaults.assistChipColors(
- labelColor = Color(contact.nodeColors.first),
- containerColor = Color(contact.nodeColors.second),
- )
- } else {
- AssistChipDefaults.assistChipColors()
- }
+ contact.nodeColors?.let {
+ AssistChipDefaults.assistChipColors(labelColor = Color(it.first), containerColor = Color(it.second))
+ } ?: AssistChipDefaults.assistChipColors()
Row(modifier = modifier.padding(0.dp), verticalAlignment = Alignment.CenterVertically) {
AssistChip(
@@ -159,7 +155,7 @@ private fun ContactHeader(
text = contact.longName,
)
Text(
- text = contact.lastMessageTime.orEmpty(),
+ text = contact.lastMessageTime?.let { DateFormatter.formatShortDate(it) }.orEmpty(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier,
@@ -221,7 +217,7 @@ private fun ContactItemPreview() {
contactKey = "0^all",
shortName = stringResource(Res.string.some_username),
longName = stringResource(Res.string.unknown_username),
- lastMessageTime = "Mon",
+ lastMessageTime = 0L,
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
@@ -235,7 +231,7 @@ private fun ContactItemPreview() {
sampleContact.copy(
shortName = "0",
longName = "A very long contact name that should be truncated.",
- lastMessageTime = "15 minutes ago",
+ lastMessageTime = 1000L,
),
)
diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
similarity index 96%
rename from app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
rename to feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
index 6346bc8ce..b5b7016c8 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
@@ -14,8 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.ui.contact
+package org.meshtastic.feature.messaging.ui.contact
+import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -57,8 +58,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
-import com.geeksville.mesh.model.Contact
-import com.geeksville.mesh.model.UIViewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@@ -67,6 +66,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.ContactSettings
+import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
@@ -106,15 +106,20 @@ import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.SharedContact
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalPermissionsApi::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
- viewModel: ContactsViewModel = hiltViewModel(),
- uIViewModel: UIViewModel = hiltViewModel(),
+ sharedContactRequested: SharedContact?,
+ requestChannelSet: ChannelSet?,
+ onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
+ onClearSharedContactRequested: () -> Unit,
+ onClearRequestChannelUrl: () -> Unit,
+ viewModel: ContactsViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
@@ -144,7 +149,7 @@ fun ContactsScreen(
contactKey = "$ch^all",
shortName = "$ch",
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
- lastMessageTime = "",
+ lastMessageTime = null,
lastMessageText = "",
unreadCount = 0,
messageCount = 0,
@@ -180,9 +185,7 @@ fun ContactsScreen(
}
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
- val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
- val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
- requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { uIViewModel.clearRequestChannelUrl() }) }
+ requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { onClearRequestChannelUrl() }) }
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
@@ -241,12 +244,10 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uri ->
- uIViewModel.handleScannedUri(uri) {
- scope.launch { context.showToast(Res.string.channel_invalid) }
- }
+ onHandleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
},
onShareChannels = onNavigateToShare,
- onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
+ onDismissSharedContact = { onClearSharedContactRequested() },
isContactContext = true,
)
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
similarity index 84%
rename from app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt
rename to feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
index 0ee5ed8d8..0826fe713 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/ContactsViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
@@ -14,14 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.ui.contact
+package org.meshtastic.feature.messaging.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
-import com.geeksville.mesh.model.Contact
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -29,18 +28,15 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
-import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
-import org.meshtastic.core.model.util.getShortDate
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.channel_name
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
@@ -96,26 +92,27 @@ constructor(
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
- val fromLocal = data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)
+ val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId))
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
- val user = getUser(if (fromLocal) data.to else data.from)
- val node = getNode(if (fromLocal) data.to else data.from)
+ val userId = if (fromLocal) data.to else data.from
+ val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
+ val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
- val shortName = user.short_name ?: ""
+ val shortName = user.short_name
val longName =
if (toBroadcast) {
- channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
+ channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
} else {
- user.long_name ?: ""
+ user.long_name
}
Contact(
contactKey = contactKey,
- shortName = if (toBroadcast) "${data.channel}" else shortName,
+ shortName = if (toBroadcast) data.channel.toString() else shortName,
longName = longName,
- lastMessageTime = getShortDate(data.time),
+ lastMessageTime = if (data.time != 0L) data.time else null,
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
@@ -138,7 +135,6 @@ constructor(
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
}
.flatMapLatest { params ->
- val myNodeNum = params.myNodeNum
val channelSet = params.channelSet
val settings = params.settings
val myId = params.myId
@@ -149,26 +145,27 @@ constructor(
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
- val fromLocal = data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId)
+ val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId))
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
- val user = getUser(if (fromLocal) data.to else data.from)
- val node = getNode(if (fromLocal) data.to else data.from)
+ val userId = if (fromLocal) data.to else data.from
+ val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
+ val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
- val shortName = user.short_name ?: ""
+ val shortName = user.short_name
val longName =
if (toBroadcast) {
- channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
+ channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
} else {
- user.long_name ?: ""
+ user.long_name
}
Contact(
contactKey = contactKey,
- shortName = if (toBroadcast) "${data.channel}" else shortName,
+ shortName = if (toBroadcast) data.channel.toString() else shortName,
longName = longName,
- lastMessageTime = getShortDate(data.time),
+ lastMessageTime = if (data.time != 0L) data.time else null,
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
@@ -210,8 +207,6 @@ constructor(
contactKeys.sumOf { contactKey -> packetRepository.getMessageCount(contactKey) }
}
- private fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
-
private data class ContactsPagedParams(
val myNodeNum: Int?,
val channelSet: ChannelSet,
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt
similarity index 94%
rename from app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt
rename to feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt
index 0694ae7c2..6e351ebed 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Share.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/sharing/Share.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.ui.sharing
+package org.meshtastic.feature.messaging.ui.sharing
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -38,10 +38,8 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.geeksville.mesh.model.Contact
-import com.geeksville.mesh.ui.contact.ContactItem
-import com.geeksville.mesh.ui.contact.ContactsViewModel
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.Contact
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.share
@@ -50,6 +48,8 @@ import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.feature.messaging.ui.contact.ContactItem
+import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@Composable
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
@@ -116,7 +116,7 @@ private fun ShareScreenPreview() {
contactKey = "0^all",
shortName = stringResource(Res.string.some_username),
longName = stringResource(Res.string.unknown_username),
- lastMessageTime = "3 minutes ago",
+ lastMessageTime = 0L,
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
index 8497a5b01..fb1710ba2 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -29,12 +29,12 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import org.meshtastic.core.common.util.bearing
+import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.util.bearing
-import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.ui.component.precisionBitsToMeters
import org.meshtastic.proto.Config
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt
index 1098f03bb..f0a35b489 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt
@@ -40,8 +40,8 @@ import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt
index 6b9dc777f..d626be2d4 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.metrics
-import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@@ -39,11 +38,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.neighbor_info
@@ -85,8 +84,6 @@ fun NeighborInfoLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
- val context = LocalContext.current
-
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
@@ -128,12 +125,7 @@ fun NeighborInfoLogScreen(
}
}
- val time =
- DateUtils.formatDateTime(
- context,
- log.received_date,
- DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
- )
+ val time = DateFormatter.formatDateTime(log.received_date)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
val header = stringResource(Res.string.neighbor_info)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
index 0846fa756..dcadc596d 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.metrics
-import android.text.format.DateUtils
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
@@ -40,7 +39,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.PreviewLightDark
@@ -49,6 +47,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
@@ -106,7 +105,6 @@ fun TracerouteLogScreen(
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
- val context = LocalContext.current
val statusGreen = MaterialTheme.colorScheme.StatusGreen
val statusYellow = MaterialTheme.colorScheme.StatusYellow
val statusOrange = MaterialTheme.colorScheme.StatusOrange
@@ -151,12 +149,7 @@ fun TracerouteLogScreen(
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
- val time =
- DateUtils.formatDateTime(
- context,
- log.received_date,
- DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
- )
+ val time = DateFormatter.formatDateTime(log.received_date)
val (text, icon) = route.getTextAndIcon()
var expanded by remember { mutableStateOf(false) }
@@ -278,12 +271,7 @@ private fun RouteDiscovery?.getTextAndIcon(): Pair = when {
@PreviewLightDark
@Composable
private fun TracerouteItemPreview() {
- val time =
- DateUtils.formatDateTime(
- LocalContext.current,
- nowMillis,
- DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
- )
+ val time = DateFormatter.formatDateTime(nowMillis)
AppTheme {
MetricLogItem(
icon = MeshtasticIcons.Group,
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
index 4e30a5f3d..edb4a4950 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt
@@ -43,6 +43,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.extractWifiCredentials
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.model.util.handleMeshtasticUri
+import org.meshtastic.core.model.util.toCommonUri
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
@@ -120,7 +121,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
if (contents != null) {
val handled =
handleMeshtasticUri(
- uri = contents.toUri(),
+ uri = contents.toUri().toCommonUri(),
onChannel = {}, // No-op, not supported in network config
onContact = {}, // No-op, not supported in network config
)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a1350f5ef..3514770fa 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -50,6 +50,7 @@ detekt = "1.23.8"
dokka = "2.2.0-Beta"
devtools-ksp = "2.3.6"
markdownRenderer = "0.39.2"
+okio = "3.10.2"
osmdroid-android = "6.1.20"
spotless = "8.2.1"
wire = "6.0.0-alpha02"
@@ -208,6 +209,7 @@ nordic-common-scanner-ble = { module = "no.nordicsemi.android.common:scanner-ble
nordic-common-ui = { module = "no.nordicsemi.android.common:ui", version.ref = "nordic-common" }
org-eclipse-paho-client-mqttv3 = { module = "org.eclipse.paho:org.eclipse.paho.client.mqttv3", version = "1.2.5" }
+okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }
osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" }