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" }